voxcity 0.7.0__py3-none-any.whl → 1.0.2__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 (42) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/exporter/__init__.py +12 -12
  3. voxcity/exporter/cityles.py +633 -633
  4. voxcity/exporter/envimet.py +733 -728
  5. voxcity/exporter/magicavoxel.py +333 -333
  6. voxcity/exporter/netcdf.py +238 -238
  7. voxcity/exporter/obj.py +1480 -1480
  8. voxcity/generator/__init__.py +47 -44
  9. voxcity/generator/api.py +721 -675
  10. voxcity/generator/grids.py +381 -379
  11. voxcity/generator/io.py +94 -94
  12. voxcity/generator/pipeline.py +282 -282
  13. voxcity/generator/update.py +429 -0
  14. voxcity/generator/voxelizer.py +18 -6
  15. voxcity/geoprocessor/__init__.py +75 -75
  16. voxcity/geoprocessor/draw.py +1488 -1219
  17. voxcity/geoprocessor/merge_utils.py +91 -91
  18. voxcity/geoprocessor/mesh.py +806 -806
  19. voxcity/geoprocessor/network.py +708 -708
  20. voxcity/geoprocessor/raster/buildings.py +435 -428
  21. voxcity/geoprocessor/raster/export.py +93 -93
  22. voxcity/geoprocessor/raster/landcover.py +5 -2
  23. voxcity/geoprocessor/utils.py +824 -824
  24. voxcity/models.py +113 -113
  25. voxcity/simulator/solar/__init__.py +66 -43
  26. voxcity/simulator/solar/integration.py +336 -336
  27. voxcity/simulator/solar/sky.py +668 -0
  28. voxcity/simulator/solar/temporal.py +792 -434
  29. voxcity/utils/__init__.py +11 -0
  30. voxcity/utils/classes.py +194 -0
  31. voxcity/utils/lc.py +80 -39
  32. voxcity/utils/shape.py +230 -0
  33. voxcity/visualizer/__init__.py +24 -24
  34. voxcity/visualizer/builder.py +43 -43
  35. voxcity/visualizer/grids.py +141 -141
  36. voxcity/visualizer/maps.py +187 -187
  37. voxcity/visualizer/renderer.py +1145 -928
  38. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/METADATA +90 -49
  39. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/RECORD +42 -38
  40. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  41. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  42. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,729 +1,734 @@
1
- """ENVI-met model file exporter module.
2
-
3
- This module provides functionality to export voxel city data to ENVI-met INX format.
4
- ENVI-met is a three-dimensional microclimate model designed to simulate surface-plant-air
5
- interactions in urban environments.
6
-
7
- Key Features:
8
- - Converts voxel grids to ENVI-met compatible format
9
- - Handles building heights, vegetation, materials, and terrain
10
- - Supports telescoping grid for vertical mesh refinement
11
- - Generates complete INX files with all required parameters
12
- - Creates plant database (EDB) files for 3D vegetation
13
-
14
- Main Functions:
15
- - prepare_grids: Processes input grids for ENVI-met format
16
- - create_xml_content: Generates INX file XML content
17
- - export_inx: Main function to export model to INX format
18
- - generate_edb_file: Creates plant database file
19
- - array_to_string: Helper functions for grid formatting
20
-
21
- Dependencies:
22
- - numpy: For array operations
23
- - datetime: For timestamp generation
24
- """
25
-
26
- import os
27
- import numpy as np
28
- import datetime
29
-
30
- from ..geoprocessor.raster import apply_operation, translate_array, group_and_label_cells, process_grid
31
- from ..geoprocessor.utils import get_city_country_name_from_rectangle, get_timezone_info
32
- from ..utils.lc import convert_land_cover
33
- from ..models import VoxCity
34
-
35
- def array_to_string(arr):
36
- """Convert a 2D numpy array to a string representation with comma-separated values.
37
-
38
- This function formats array values for ENVI-met INX files, where each row must be:
39
- 1. Indented by 5 spaces
40
- 2. Values separated by commas
41
- 3. No trailing comma
42
-
43
- Args:
44
- arr (numpy.ndarray): 2D numpy array to convert
45
-
46
- Returns:
47
- str: String representation with each row indented by 5 spaces and values comma-separated
48
-
49
- Example:
50
- >>> arr = np.array([[1, 2], [3, 4]])
51
- >>> print(array_to_string(arr))
52
- 1,2
53
- 3,4
54
- """
55
- return '\n'.join(' ' + ','.join(str(cell) for cell in row) for row in arr)
56
-
57
- def array_to_string_with_value(arr, value):
58
- """Convert a 2D numpy array to a string representation, replacing all values with a constant.
59
-
60
- This function is useful for creating uniform value grids in ENVI-met INX files,
61
- such as for soil profiles or fixed height indicators.
62
-
63
- Args:
64
- arr (numpy.ndarray): 2D numpy array to convert (only shape is used)
65
- value (str or numeric): Value to use for all cells
66
-
67
- Returns:
68
- str: String representation with each row indented by 5 spaces and constant value repeated
69
-
70
- Example:
71
- >>> arr = np.zeros((2, 2))
72
- >>> print(array_to_string_with_value(arr, '0'))
73
- 0,0
74
- 0,0
75
- """
76
- return '\n'.join(' ' + ','.join(str(value) for cell in row) for row in arr)
77
-
78
- def array_to_string_int(arr):
79
- """Convert a 2D numpy array to a string representation of rounded integers.
80
-
81
- This function is used for grids that must be represented as integers in ENVI-met,
82
- such as building numbers or terrain heights. Values are rounded to nearest integer.
83
-
84
- Args:
85
- arr (numpy.ndarray): 2D numpy array to convert
86
-
87
- Returns:
88
- str: String representation with each row indented by 5 spaces and values rounded to integers
89
-
90
- Example:
91
- >>> arr = np.array([[1.6, 2.3], [3.7, 4.1]])
92
- >>> print(array_to_string_int(arr))
93
- 2,2
94
- 4,4
95
- """
96
- return '\n'.join(' ' + ','.join(str(int(cell+0.5)) for cell in row) for row in arr)
97
-
98
- def prepare_grids(building_height_grid_ori, building_id_grid_ori, canopy_height_grid_ori, land_cover_grid_ori, dem_grid_ori, meshsize, land_cover_source):
99
- """Prepare and process input grids for ENVI-met model.
100
-
101
- This function performs several key transformations on input grids:
102
- 1. Flips grids vertically to match ENVI-met coordinate system
103
- 2. Handles missing values and border conditions
104
- 3. Converts land cover classes to ENVI-met vegetation and material codes
105
- 4. Processes building IDs and heights
106
- 5. Adjusts DEM relative to minimum elevation
107
-
108
- Args:
109
- building_height_grid_ori (numpy.ndarray): Original building height grid (meters)
110
- building_id_grid_ori (numpy.ndarray): Original building ID grid
111
- canopy_height_grid_ori (numpy.ndarray): Original canopy height grid (meters)
112
- land_cover_grid_ori (numpy.ndarray): Original land cover grid (class codes)
113
- dem_grid_ori (numpy.ndarray): Original DEM grid (meters)
114
- meshsize (float): Size of mesh cells in meters
115
- land_cover_source (str): Source of land cover data for class conversion
116
-
117
- Returns:
118
- tuple: Processed grids:
119
- - building_height_grid (numpy.ndarray): Building heights
120
- - building_id_grid (numpy.ndarray): Building IDs
121
- - land_cover_veg_grid (numpy.ndarray): Vegetation codes
122
- - land_cover_mat_grid (numpy.ndarray): Material codes
123
- - canopy_height_grid (numpy.ndarray): Canopy heights
124
- - dem_grid (numpy.ndarray): Processed DEM
125
-
126
- Notes:
127
- - Building heights at grid borders are set to 0
128
- - DEM is normalized to minimum elevation
129
- - Land cover is converted based on source-specific mapping
130
- """
131
- # Flip building height grid vertically and replace NaN with 10m height
132
- building_height_grid = np.flipud(np.nan_to_num(building_height_grid_ori, nan=10.0)).copy()
133
- building_id_grid = np.flipud(building_id_grid_ori)
134
-
135
- # Set border cells to 0 height
136
- building_height_grid[0, :] = building_height_grid[-1, :] = building_height_grid[:, 0] = building_height_grid[:, -1] = 0
137
- building_height_grid = apply_operation(building_height_grid, meshsize)
138
-
139
- # Convert land cover if needed based on source
140
- if (land_cover_source == 'OpenEarthMapJapan') or (land_cover_source == 'OpenStreetMap'):
141
- land_cover_grid_converted = land_cover_grid_ori
142
- else:
143
- land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=land_cover_source)
144
-
145
- land_cover_grid = np.flipud(land_cover_grid_converted).copy() + 1
146
-
147
- # Dictionary mapping land cover types to vegetation codes
148
- veg_translation_dict = {
149
- 1: '', # Bareland
150
- 2: '0200XX', # Rangeland
151
- 3: '0200H1', # Shrub
152
- 4: '0200XX', # Moss and lichen
153
- 5: '0200XX', # Agriculture land
154
- 6: '', # Tree
155
- 7: '0200XX', # Wet land
156
- 8: '' # Mangroves
157
- }
158
- land_cover_veg_grid = translate_array(land_cover_grid, veg_translation_dict)
159
-
160
- # Dictionary mapping land cover types to material codes
161
- mat_translation_dict = {
162
- 1: '000000', # Bareland
163
- 2: '000000', # Rangeland
164
- 3: '000000', # Shrub
165
- 4: '000000', # Moss and lichen
166
- 5: '000000', # Agriculture land
167
- 6: '000000', # Tree
168
- 7: '0200WW', # Wet land
169
- 8: '0200WW', # Mangroves
170
- 9: '0200WW', # Water
171
- 10: '000000', # Snow and ice
172
- 11: '0200PG', # Developed space
173
- 12: '0200ST', # Road
174
- 13: '000000', # Building
175
- 14: '000000', # No Data
176
- }
177
- land_cover_mat_grid = translate_array(land_cover_grid, mat_translation_dict)
178
-
179
- # Process canopy and DEM grids
180
- canopy_height_grid = canopy_height_grid_ori.copy()
181
- dem_grid = np.flipud(dem_grid_ori).copy() - np.min(dem_grid_ori)
182
-
183
- return building_height_grid, building_id_grid, land_cover_veg_grid, land_cover_mat_grid, canopy_height_grid, dem_grid
184
-
185
- def create_xml_content(building_height_grid, building_id_grid, land_cover_veg_grid, land_cover_mat_grid, canopy_height_grid, dem_grid, meshsize, rectangle_vertices, **kwargs):
186
- """Create XML content for ENVI-met INX file.
187
-
188
- This function generates the complete XML structure for an ENVI-met INX file,
189
- including model metadata, geometry settings, and all required grid data.
190
-
191
- Args:
192
- building_height_grid (numpy.ndarray): Processed building heights
193
- building_id_grid (numpy.ndarray): Processed building IDs
194
- land_cover_veg_grid (numpy.ndarray): Vegetation codes grid
195
- land_cover_mat_grid (numpy.ndarray): Material codes grid
196
- canopy_height_grid (numpy.ndarray): Processed canopy heights
197
- dem_grid (numpy.ndarray): Processed DEM
198
- meshsize (float): Size of mesh cells in meters
199
- rectangle_vertices (list): Vertices defining model area as [(lon, lat), ...]
200
- **kwargs: Additional keyword arguments:
201
- - author_name (str): Name of model author
202
- - model_description (str): Description of model
203
- - domain_building_max_height_ratio (float): Ratio of domain height to max building height
204
- - useTelescoping_grid (bool): Whether to use telescoping grid
205
- - verticalStretch (float): Vertical stretch factor
206
- - startStretch (float): Height to start stretching
207
- - min_grids_Z (int): Minimum vertical grid cells
208
-
209
- Returns:
210
- str: Complete XML content for INX file
211
-
212
- Notes:
213
- - Automatically determines location information from coordinates
214
- - Handles both telescoping and uniform vertical grids
215
- - Sets appropriate defaults for optional parameters
216
- - Includes all required ENVI-met model settings
217
- """
218
- # XML template defining the structure of an ENVI-met INX file
219
- xml_template = """<ENVI-MET_Datafile>
220
- <Header>
221
- <filetype>INPX ENVI-met Area Input File</filetype>
222
- <version>440</version>
223
- <revisiondate>7/5/2024 5:44:52 PM</revisiondate>
224
- <remark>Created with SPACES 5.6.1</remark>
225
- <checksum>0</checksum>
226
- <encryptionlevel>0</encryptionlevel>
227
- </Header>
228
- <baseData>
229
- <modelDescription> $modelDescription$ </modelDescription>
230
- <modelAuthor> $modelAuthor$ </modelAuthor>
231
- <modelcopyright> The creator or distributor is responsible for following Copyright Laws </modelcopyright>
232
- </baseData>
233
- <modelGeometry>
234
- <grids-I> $grids-I$ </grids-I>
235
- <grids-J> $grids-J$ </grids-J>
236
- <grids-Z> $grids-Z$ </grids-Z>
237
- <dx> $dx$ </dx>
238
- <dy> $dy$ </dy>
239
- <dz-base> $dz-base$ </dz-base>
240
- <useTelescoping_grid> $useTelescoping_grid$ </useTelescoping_grid>
241
- <useSplitting> 1 </useSplitting>
242
- <verticalStretch> $verticalStretch$ </verticalStretch>
243
- <startStretch> $startStretch$ </startStretch>
244
- <has3DModel> 0 </has3DModel>
245
- <isFull3DDesign> 0 </isFull3DDesign>
246
- </modelGeometry>
247
- <nestingArea>
248
- <numberNestinggrids> 0 </numberNestinggrids>
249
- <soilProfileA> 000000 </soilProfileA>
250
- <soilProfileB> 000000 </soilProfileB>
251
- </nestingArea>
252
- <locationData>
253
- <modelRotation> $modelRotation$ </modelRotation>
254
- <projectionSystem> $projectionSystem$ </projectionSystem>
255
- <UTMZone> 0 </UTMZone>
256
- <realworldLowerLeft_X> 0.00000 </realworldLowerLeft_X>
257
- <realworldLowerLeft_Y> 0.00000 </realworldLowerLeft_Y>
258
- <locationName> $locationName$ </locationName>
259
- <location_Longitude> $location_Longitude$ </location_Longitude>
260
- <location_Latitude> $location_Latitude$ </location_Latitude>
261
- <locationTimeZone_Name> $locationTimeZone_Name$ </locationTimeZone_Name>
262
- <locationTimeZone_Longitude> $locationTimeZone_Longitude$ </locationTimeZone_Longitude>
263
- </locationData>
264
- <defaultSettings>
265
- <commonWallMaterial> 000000 </commonWallMaterial>
266
- <commonRoofMaterial> 000000 </commonRoofMaterial>
267
- </defaultSettings>
268
- <buildings2D>
269
- <zTop type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
270
- $zTop$
271
- </zTop>
272
- <zBottom type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
273
- $zBottom$
274
- </zBottom>
275
- <buildingNr type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
276
- $buildingNr$
277
- </buildingNr>
278
- <fixedheight type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
279
- $fixedheight$
280
- </fixedheight>
281
- </buildings2D>
282
- <simpleplants2D>
283
- <ID_plants1D type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
284
- $ID_plants1D$
285
- </ID_plants1D>
286
- </simpleplants2D>
287
- $3Dplants$
288
- <soils2D>
289
- <ID_soilprofile type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
290
- $ID_soilprofile$
291
- </ID_soilprofile>
292
- </soils2D>
293
- <dem>
294
- <DEMReference> $DEMReference$ </DEMReference>
295
- <terrainheight type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
296
- $terrainheight$
297
- </terrainheight>
298
- </dem>
299
- <sources2D>
300
- <ID_sources type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
301
- $ID_sources$
302
- </ID_sources>
303
- </sources2D>
304
- </ENVI-MET_Datafile>"""
305
-
306
- # Get location information based on rectangle vertices
307
- city_country_name = get_city_country_name_from_rectangle(rectangle_vertices)
308
-
309
- # Calculate center coordinates of the model area
310
- longitudes = [coord[0] for coord in rectangle_vertices] # Changed order from lat to lon
311
- latitudes = [coord[1] for coord in rectangle_vertices] # Changed order from lat to lon
312
- center_lon = str(sum(longitudes) / len(longitudes)) # Changed order
313
- center_lat = str(sum(latitudes) / len(latitudes)) # Changed order
314
-
315
- timezone_info = get_timezone_info(rectangle_vertices)
316
-
317
- # Set default values for optional parameters
318
- author_name = kwargs.get('author_name')
319
- if author_name is None:
320
- author_name = "[Enter model author name]"
321
- model_desctiption = kwargs.get('model_desctiption')
322
- if model_desctiption is None:
323
- model_desctiption = "[Enter model desctription]"
324
-
325
- # Replace location-related placeholders in template
326
- placeholders = {
327
- "$modelDescription$": model_desctiption,
328
- "$modelAuthor$": author_name,
329
- "$modelRotation$": "0",
330
- "$projectionSystem$": "GCS_WGS_1984",
331
- "$locationName$": city_country_name,
332
- "$location_Longitude$": center_lon,
333
- "$location_Latitude$": center_lat,
334
- "$locationTimeZone_Name$": timezone_info[0],
335
- "$locationTimeZone_Longitude$": timezone_info[1],
336
- }
337
-
338
- # Ensure no None values are passed to replace()
339
- for placeholder, value in placeholders.items():
340
- if value is None:
341
- print(f"Warning: {placeholder} is None, using fallback value")
342
- if placeholder == "$locationName$":
343
- value = "Unknown Location/ Unknown Country"
344
- elif placeholder == "$locationTimeZone_Name$":
345
- value = "UTC+00:00"
346
- elif placeholder == "$locationTimeZone_Longitude$":
347
- value = "0.00000"
348
- elif placeholder == "$modelDescription$":
349
- value = "[Enter model description]"
350
- elif placeholder == "$modelAuthor$":
351
- value = "[Enter model author name]"
352
- else:
353
- value = "Unknown"
354
- xml_template = xml_template.replace(placeholder, str(value))
355
-
356
- # Calculate building heights including terrain elevation
357
- building_on_dem_grid = building_height_grid + dem_grid
358
-
359
- # Configure vertical grid settings
360
- domain_building_max_height_ratio = kwargs.get('domain_building_max_height_ratio')
361
- if domain_building_max_height_ratio is None:
362
- domain_building_max_height_ratio = 2
363
-
364
- # Configure telescoping grid settings if enabled
365
- useTelescoping_grid = kwargs.get('useTelescoping_grid')
366
- if (useTelescoping_grid is None) or (useTelescoping_grid == False):
367
- useTelescoping_grid = 0
368
- verticalStretch = 0
369
- startStretch = 0
370
- else:
371
- useTelescoping_grid = 1
372
- verticalStretch = kwargs.get('verticalStretch')
373
- if (verticalStretch is None):
374
- verticalStretch = 20
375
- startStretch = kwargs.get('startStretch')
376
- if (startStretch is None):
377
- startStretch = int(np.max(building_on_dem_grid)/meshsize + 0.5) * meshsize
378
-
379
- # Set horizontal grid dimensions
380
- grids_I, grids_J = building_height_grid.shape[1], building_height_grid.shape[0]
381
-
382
- # Calculate vertical grid dimension based on building heights and telescoping settings
383
- min_grids_Z = kwargs.get('min_grids_Z', 20)
384
- if verticalStretch > 0:
385
- # Calculate minimum number of cells needed to reach target height with telescoping
386
- a = meshsize # First cell size
387
- r = (100 + verticalStretch) / 100 # Growth ratio
388
- S_target = (int(np.max(building_on_dem_grid)/meshsize + 0.5) * meshsize) * (domain_building_max_height_ratio - 1)
389
- min_n = find_min_n(a, r, S_target, max_n=1000000)
390
- if min_n is None:
391
- # Fallback to non-telescoping grid if calculation fails
392
- print("Warning: Telescoping grid calculation failed, using uniform grid")
393
- grids_Z = max(int(np.max(building_on_dem_grid)/meshsize + 0.5) * domain_building_max_height_ratio, min_grids_Z)
394
- else:
395
- grids_Z_tent = int(np.max(building_on_dem_grid)/meshsize + 0.5) + min_n
396
- if grids_Z_tent < min_grids_Z:
397
- grids_Z = min_grids_Z
398
- startStretch += (min_grids_Z - grids_Z)
399
- else:
400
- grids_Z = grids_Z_tent
401
- else:
402
- # Calculate vertical grid cells without telescoping
403
- grids_Z = max(int(np.max(building_on_dem_grid)/meshsize + 0.5) * domain_building_max_height_ratio, min_grids_Z)
404
-
405
- # Set grid cell sizes
406
- dx, dy, dz_base = meshsize, meshsize, meshsize
407
-
408
- # Replace grid-related placeholders
409
- grid_placeholders = {
410
- "$grids-I$": str(grids_I),
411
- "$grids-J$": str(grids_J),
412
- "$grids-Z$": str(grids_Z),
413
- "$dx$": str(dx),
414
- "$dy$": str(dy),
415
- "$dz-base$": str(dz_base),
416
- "$useTelescoping_grid$": str(useTelescoping_grid),
417
- "$verticalStretch$": str(verticalStretch),
418
- "$startStretch$": str(startStretch),
419
- }
420
-
421
- for placeholder, value in grid_placeholders.items():
422
- xml_template = xml_template.replace(placeholder, value)
423
-
424
- # Replace matrix data placeholders with actual grid data
425
- xml_template = xml_template.replace("$zTop$", array_to_string(building_height_grid))
426
- xml_template = xml_template.replace("$zBottom$", array_to_string_with_value(building_height_grid, '0'))
427
- xml_template = xml_template.replace("$fixedheight$", array_to_string_with_value(building_height_grid, '0'))
428
-
429
- # Process and add building numbers
430
- building_nr_grid = group_and_label_cells(building_id_grid)
431
- xml_template = xml_template.replace("$buildingNr$", array_to_string(building_nr_grid))
432
-
433
- # Add vegetation data
434
- xml_template = xml_template.replace("$ID_plants1D$", array_to_string(land_cover_veg_grid))
435
-
436
- # Generate and add 3D plant data
437
- tree_content = ""
438
- for i in range(grids_I):
439
- for j in range(grids_J):
440
- canopy_height = int(canopy_height_grid[j, i] + 0.5)
441
- # Only add trees where there are no buildings
442
- if canopy_height_grid[j, i] > 0 and np.flipud(building_height_grid)[j, i]==0:
443
- plantid = f'H{canopy_height:02d}W01'
444
- tree_ij = f""" <3Dplants>
445
- <rootcell_i> {i+1} </rootcell_i>
446
- <rootcell_j> {j+1} </rootcell_j>
447
- <rootcell_k> 0 </rootcell_k>
448
- <plantID> {plantid} </plantID>
449
- <name> .{plantid} </name>
450
- <observe> 0 </observe>
451
- </3Dplants>"""
452
- tree_content += '\n' + tree_ij
453
-
454
- # Add remaining data
455
- xml_template = xml_template.replace("$3Dplants$", tree_content)
456
- xml_template = xml_template.replace("$ID_soilprofile$", array_to_string(land_cover_mat_grid))
457
- dem_grid = process_grid(building_nr_grid, dem_grid)
458
- xml_template = xml_template.replace("$DEMReference$", '0')
459
- xml_template = xml_template.replace("$terrainheight$", array_to_string_int(dem_grid))
460
- xml_template = xml_template.replace("$ID_sources$", array_to_string_with_value(land_cover_mat_grid, ''))
461
-
462
- return xml_template
463
-
464
- def save_file(content, output_file_path):
465
- """Save content to a file with UTF-8 encoding.
466
-
467
- This function ensures consistent file encoding and error handling when
468
- saving ENVI-met files.
469
-
470
- Args:
471
- content (str): String content to save
472
- output_file_path (str): Path to save file to
473
-
474
- Notes:
475
- - Creates parent directories if they don't exist
476
- - Uses UTF-8 encoding for compatibility
477
- - Overwrites existing file if present
478
- """
479
- with open(output_file_path, 'w', encoding='utf-8') as file:
480
- file.write(content)
481
-
482
- def export_inx(city: VoxCity, output_directory: str, file_basename: str = 'voxcity', land_cover_source: str | None = None, **kwargs):
483
- """Export model data to ENVI-met INX file format.
484
-
485
- This is the main function for exporting voxel city data to ENVI-met format.
486
- It coordinates the entire export process from grid preparation to file saving.
487
-
488
- Args:
489
- city (VoxCity): VoxCity instance to export
490
- output_directory (str): Directory to save output
491
- file_basename (str): Base filename (without extension)
492
- land_cover_source (str | None): Optional override for land cover source; defaults to city.extras
493
- **kwargs: Additional keyword arguments passed to create_xml_content()
494
-
495
- Notes:
496
- - Creates output directory if it doesn't exist
497
- - Handles grid preparation and transformation
498
- - Generates complete INX file with all required data
499
- - Uses standardized file naming convention
500
- """
501
- # Resolve inputs from VoxCity
502
- meshsize = float(city.voxels.meta.meshsize)
503
- rectangle_vertices = city.extras.get("rectangle_vertices") or [(0.0, 0.0)] * 4
504
- lc_source = land_cover_source or city.extras.get("land_cover_source", "Standard")
505
-
506
- # Prepare grids
507
- building_height_grid_inx, building_id_grid, land_cover_veg_grid_inx, land_cover_mat_grid_inx, canopy_height_grid_inx, dem_grid_inx = prepare_grids(
508
- city.buildings.heights.copy(),
509
- (city.buildings.ids if city.buildings.ids is not None else np.zeros_like(city.buildings.heights, dtype=int)).copy(),
510
- (city.tree_canopy.top if city.tree_canopy is not None else np.zeros_like(city.land_cover.classes, dtype=float)).copy(),
511
- city.land_cover.classes.copy(),
512
- city.dem.elevation.copy(),
513
- meshsize,
514
- lc_source)
515
-
516
- # Create XML content
517
- xml_content = create_xml_content(building_height_grid_inx, building_id_grid, land_cover_veg_grid_inx, land_cover_mat_grid_inx, canopy_height_grid_inx, dem_grid_inx, meshsize, rectangle_vertices, **kwargs)
518
-
519
- # Save the output
520
- output_dir = output_directory or 'output'
521
- os.makedirs(output_dir, exist_ok=True)
522
- output_file_path = os.path.join(output_dir, f"{file_basename}.INX")
523
- save_file(xml_content, output_file_path)
524
-
525
-
526
- class EnvimetExporter:
527
- """Exporter adapter to write a VoxCity model to ENVI-met INX format."""
528
-
529
- def export(self, obj, output_directory: str, base_filename: str, **kwargs):
530
- if not isinstance(obj, VoxCity):
531
- raise TypeError("EnvimetExporter expects a VoxCity instance")
532
- city: VoxCity = obj
533
- export_inx(
534
- city,
535
- output_directory=output_directory,
536
- file_basename=base_filename,
537
- **kwargs,
538
- )
539
- return os.path.join(output_directory, f"{base_filename}.INX")
540
-
541
- def generate_edb_file(**kwargs):
542
- """Generate ENVI-met database file for 3D plants.
543
-
544
- Creates a plant database file (EDB) containing definitions for trees of
545
- different heights with customizable leaf area density profiles.
546
-
547
- Args:
548
- **kwargs: Keyword arguments:
549
- - lad (float): Leaf area density in m²/m³ (default 1.0)
550
- - trunk_height_ratio (float): Ratio of trunk height to total height
551
- (default 11.76/19.98)
552
-
553
- Notes:
554
- - Generates plants for heights from 1-50m
555
- - Uses standardized plant IDs in format 'HxxW01'
556
- - Includes physical properties like wood density
557
- - Sets seasonal variation profiles
558
- - Creates complete ENVI-met plant database format
559
- """
560
-
561
- lad = kwargs.get('lad')
562
- if lad is None:
563
- lad=1.0
564
-
565
- trunk_height_ratio = kwargs.get("trunk_height_ratio")
566
- if trunk_height_ratio is None:
567
- trunk_height_ratio = 11.76 / 19.98
568
-
569
- # Create header with current timestamp
570
- header = f'''<ENVI-MET_Datafile>
571
- <Header>
572
- <filetype>DATA</filetype>
573
- <version>1</version>
574
- <revisiondate>{datetime.datetime.now().strftime("%m/%d/%Y %I:%M:%S %p")}</revisiondate>
575
- <remark>Envi-Data</remark>
576
- <checksum>0</checksum>
577
- <encryptionlevel>1699612</encryptionlevel>
578
- </Header>
579
- '''
580
-
581
- footer = '</ENVI-MET_Datafile>'
582
-
583
- # Generate plant definitions for heights 1-50m
584
- plant3d_objects = []
585
-
586
- for height in range(1, 51):
587
- plant3d = f''' <PLANT3D>
588
- <ID> H{height:02d}W01 </ID>
589
- <Description> H{height:02d}W01 </Description>
590
- <AlternativeName> Albero nuovo </AlternativeName>
591
- <Planttype> 0 </Planttype>
592
- <Leaftype> 1 </Leaftype>
593
- <Albedo> 0.18000 </Albedo>
594
- <Eps> 0.00000 </Eps>
595
- <Transmittance> 0.30000 </Transmittance>
596
- <isoprene> 12.00000 </isoprene>
597
- <leafweigth> 100.00000 </leafweigth>
598
- <rs_min> 0.00000 </rs_min>
599
- <Height> {height:.5f} </Height>
600
- <Width> 1.00000 </Width>
601
- <Depth> {height * trunk_height_ratio:.5f} </Depth>
602
- <RootDiameter> 1.00000 </RootDiameter>
603
- <cellsize> 1.00000 </cellsize>
604
- <xy_cells> 1 </xy_cells>
605
- <z_cells> {height} </z_cells>
606
- <scalefactor> 0.00000 </scalefactor>
607
- <LAD-Profile type="sparematrix-3D" dataI="1" dataJ="1" zlayers="{height}" defaultValue="0.00000">
608
- {generate_lad_profile(height, trunk_height_ratio, lad=str(lad))}
609
- </LAD-Profile>
610
- <RAD-Profile> 0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000 </RAD-Profile>
611
- <Root-Range-Profile> 1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000 </Root-Range-Profile>
612
- <Season-Profile> 0.30000,0.30000,0.30000,0.40000,0.70000,1.00000,1.00000,1.00000,0.80000,0.60000,0.30000,0.30000 </Season-Profile>
613
- <Blossom-Profile> 0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000 </Blossom-Profile>
614
- <DensityWood> 690.00000 </DensityWood>
615
- <YoungsModulus> 8770000896.00000 </YoungsModulus>
616
- <YoungRatioRtoL> 0.12000 </YoungRatioRtoL>
617
- <MORBranch> 65.00000 </MORBranch>
618
- <MORConnection> 45.00000 </MORConnection>
619
- <PlantGroup> 0 </PlantGroup>
620
- <Color> 0 </Color>
621
- <Group> </Group>
622
- <Author> </Author>
623
- <costs> 0.00000 </costs>
624
- <ColorStem> 0 </ColorStem>
625
- <ColorBlossom> 0 </ColorBlossom>
626
- <BlossomRadius> 0.00000 </BlossomRadius>
627
- <L-SystemBased> 0 </L-SystemBased>
628
- <Axiom> V </Axiom>
629
- <IterationDepth> 0 </IterationDepth>
630
- <hasUserEdits> 0 </hasUserEdits>
631
- <LADMatrix_generated> 0 </LADMatrix_generated>
632
- <InitialSegmentLength> 0.00000 </InitialSegmentLength>
633
- <SmallSegmentLength> 0.00000 </SmallSegmentLength>
634
- <ChangeSegmentLength> 0.00000 </ChangeSegmentLength>
635
- <SegmentResolution> 0.00000 </SegmentResolution>
636
- <TurtleAngle> 0.00000 </TurtleAngle>
637
- <RadiusOuterBranch> 0.00000 </RadiusOuterBranch>
638
- <PipeFactor> 0.00000 </PipeFactor>
639
- <LeafPosition> 0 </LeafPosition>
640
- <LeafsPerNode> 0 </LeafsPerNode>
641
- <LeafInternodeLength> 0.00000 </LeafInternodeLength>
642
- <LeafMinSegmentOrder> 0 </LeafMinSegmentOrder>
643
- <LeafWidth> 0.00000 </LeafWidth>
644
- <LeafLength> 0.00000 </LeafLength>
645
- <LeafSurface> 0.00000 </LeafSurface>
646
- <PetioleAngle> 0.00000 </PetioleAngle>
647
- <PetioleLength> 0.00000 </PetioleLength>
648
- <LeafRotationalAngle> 0.00000 </LeafRotationalAngle>
649
- <FactorHorizontal> 0.00000 </FactorHorizontal>
650
- <TropismVector> 0.000000,0.000000,0.000000 </TropismVector>
651
- <TropismElstaicity> 0.00000 </TropismElstaicity>
652
- <SegmentRemovallist> </SegmentRemovallist>
653
- <NrRules> 0 </NrRules>
654
- <Rules_Variable> </Rules_Variable>
655
- <Rules_Replacement> </Rules_Replacement>
656
- <Rules_isConditional> </Rules_isConditional>
657
- <Rules_Condition> </Rules_Condition>
658
- <Rules_Remark> </Rules_Remark>
659
- <TermLString> </TermLString>
660
- <ApplyTermLString> 0 </ApplyTermLString>
661
- </PLANT3D>
662
- '''
663
- plant3d_objects.append(plant3d)
664
-
665
- content = header + ''.join(plant3d_objects) + footer
666
-
667
- with open('projectdatabase.edb', 'w') as f:
668
- f.write(content)
669
-
670
- def generate_lad_profile(height, trunk_height_ratio, lad = '1.00000'):
671
- """Generate leaf area density profile for a plant.
672
-
673
- Creates a vertical profile of leaf area density (LAD) values for ENVI-met
674
- plant definitions, accounting for trunk space and crown distribution.
675
-
676
- Args:
677
- height (int): Total height of plant in meters
678
- trunk_height_ratio (float): Ratio of trunk height to total height
679
- lad (str): Leaf area density value as string (default '1.00000')
680
-
681
- Returns:
682
- str: LAD profile data formatted for ENVI-met EDB file
683
-
684
- Notes:
685
- - LAD values start above trunk height
686
- - Uses 5-space indentation for ENVI-met format
687
- - Profile follows format: "z-level,x,y,LAD"
688
- """
689
- lad_profile = []
690
- # Only add LAD values above trunk height
691
- start = max(0, int(height * trunk_height_ratio))
692
- for i in range(start, height):
693
- lad_profile.append(f" 0,0,{i},{lad}")
694
- return '\n'.join(lad_profile)
695
-
696
- def find_min_n(a, r, S_target, max_n=1000000):
697
- """Find minimum number of terms needed in geometric series to exceed target sum.
698
-
699
- Used for calculating telescoping grid parameters to achieve desired domain height.
700
- Solves for n in the equation: a(1-r^n)/(1-r) > S_target
701
-
702
- Args:
703
- a (float): First term of series (base cell size)
704
- r (float): Common ratio (stretch factor)
705
- S_target (float): Target sum to exceed (desired height)
706
- max_n (int): Maximum number of terms to try (default 1000000)
707
-
708
- Returns:
709
- int or None: Minimum number of terms needed, or None if not possible within max_n
710
-
711
- Notes:
712
- - Handles special case of r=1 (arithmetic series)
713
- - Protects against overflow with large exponents
714
- - Returns None if solution not found within max_n terms
715
- """
716
- n = 1
717
- while n <= max_n:
718
- if r == 1:
719
- S_n = a * n
720
- else:
721
- try:
722
- S_n = a * (1 - r ** n) / (1 - r)
723
- except OverflowError:
724
- # Handle large exponents
725
- S_n = float('inf') if r > 1 else 0
726
- if (a > 0 and S_n > S_target) or (a < 0 and S_n < S_target):
727
- return n
728
- n += 1
1
+ """ENVI-met model file exporter module.
2
+
3
+ This module provides functionality to export voxel city data to ENVI-met INX format.
4
+ ENVI-met is a three-dimensional microclimate model designed to simulate surface-plant-air
5
+ interactions in urban environments.
6
+
7
+ Key Features:
8
+ - Converts voxel grids to ENVI-met compatible format
9
+ - Handles building heights, vegetation, materials, and terrain
10
+ - Supports telescoping grid for vertical mesh refinement
11
+ - Generates complete INX files with all required parameters
12
+ - Creates plant database (EDB) files for 3D vegetation
13
+
14
+ Main Functions:
15
+ - prepare_grids: Processes input grids for ENVI-met format
16
+ - create_xml_content: Generates INX file XML content
17
+ - export_inx: Main function to export model to INX format
18
+ - generate_edb_file: Creates plant database file
19
+ - array_to_string: Helper functions for grid formatting
20
+
21
+ Dependencies:
22
+ - numpy: For array operations
23
+ - datetime: For timestamp generation
24
+ """
25
+
26
+ import os
27
+ import numpy as np
28
+ import datetime
29
+
30
+ from ..geoprocessor.raster import apply_operation, translate_array, group_and_label_cells, process_grid
31
+ from ..geoprocessor.utils import get_city_country_name_from_rectangle, get_timezone_info
32
+ from ..utils.lc import convert_land_cover
33
+ from ..models import VoxCity
34
+
35
+ def array_to_string(arr):
36
+ """Convert a 2D numpy array to a string representation with comma-separated values.
37
+
38
+ This function formats array values for ENVI-met INX files, where each row must be:
39
+ 1. Indented by 5 spaces
40
+ 2. Values separated by commas
41
+ 3. No trailing comma
42
+
43
+ Args:
44
+ arr (numpy.ndarray): 2D numpy array to convert
45
+
46
+ Returns:
47
+ str: String representation with each row indented by 5 spaces and values comma-separated
48
+
49
+ Example:
50
+ >>> arr = np.array([[1, 2], [3, 4]])
51
+ >>> print(array_to_string(arr))
52
+ 1,2
53
+ 3,4
54
+ """
55
+ return '\n'.join(' ' + ','.join(str(cell) for cell in row) for row in arr)
56
+
57
+ def array_to_string_with_value(arr, value):
58
+ """Convert a 2D numpy array to a string representation, replacing all values with a constant.
59
+
60
+ This function is useful for creating uniform value grids in ENVI-met INX files,
61
+ such as for soil profiles or fixed height indicators.
62
+
63
+ Args:
64
+ arr (numpy.ndarray): 2D numpy array to convert (only shape is used)
65
+ value (str or numeric): Value to use for all cells
66
+
67
+ Returns:
68
+ str: String representation with each row indented by 5 spaces and constant value repeated
69
+
70
+ Example:
71
+ >>> arr = np.zeros((2, 2))
72
+ >>> print(array_to_string_with_value(arr, '0'))
73
+ 0,0
74
+ 0,0
75
+ """
76
+ return '\n'.join(' ' + ','.join(str(value) for cell in row) for row in arr)
77
+
78
+ def array_to_string_int(arr):
79
+ """Convert a 2D numpy array to a string representation of rounded integers.
80
+
81
+ This function is used for grids that must be represented as integers in ENVI-met,
82
+ such as building numbers or terrain heights. Values are rounded to nearest integer.
83
+
84
+ Args:
85
+ arr (numpy.ndarray): 2D numpy array to convert
86
+
87
+ Returns:
88
+ str: String representation with each row indented by 5 spaces and values rounded to integers
89
+
90
+ Example:
91
+ >>> arr = np.array([[1.6, 2.3], [3.7, 4.1]])
92
+ >>> print(array_to_string_int(arr))
93
+ 2,2
94
+ 4,4
95
+ """
96
+ return '\n'.join(' ' + ','.join(str(int(cell+0.5)) for cell in row) for row in arr)
97
+
98
+ def prepare_grids(building_height_grid_ori, building_id_grid_ori, canopy_height_grid_ori, land_cover_grid_ori, dem_grid_ori, meshsize, land_cover_source):
99
+ """Prepare and process input grids for ENVI-met model.
100
+
101
+ This function performs several key transformations on input grids:
102
+ 1. Flips grids vertically to match ENVI-met coordinate system
103
+ 2. Handles missing values and border conditions
104
+ 3. Converts land cover classes to ENVI-met vegetation and material codes
105
+ 4. Processes building IDs and heights
106
+ 5. Adjusts DEM relative to minimum elevation
107
+
108
+ Args:
109
+ building_height_grid_ori (numpy.ndarray): Original building height grid (meters)
110
+ building_id_grid_ori (numpy.ndarray): Original building ID grid
111
+ canopy_height_grid_ori (numpy.ndarray): Original canopy height grid (meters)
112
+ land_cover_grid_ori (numpy.ndarray): Original land cover grid (class codes)
113
+ dem_grid_ori (numpy.ndarray): Original DEM grid (meters)
114
+ meshsize (float): Size of mesh cells in meters
115
+ land_cover_source (str): Source of land cover data for class conversion
116
+
117
+ Returns:
118
+ tuple: Processed grids:
119
+ - building_height_grid (numpy.ndarray): Building heights
120
+ - building_id_grid (numpy.ndarray): Building IDs
121
+ - land_cover_veg_grid (numpy.ndarray): Vegetation codes
122
+ - land_cover_mat_grid (numpy.ndarray): Material codes
123
+ - canopy_height_grid (numpy.ndarray): Canopy heights
124
+ - dem_grid (numpy.ndarray): Processed DEM
125
+
126
+ Notes:
127
+ - Building heights at grid borders are set to 0
128
+ - DEM is normalized to minimum elevation
129
+ - Land cover is converted based on source-specific mapping
130
+ """
131
+ # Flip building height grid vertically and replace NaN with 10m height
132
+ building_height_grid = np.flipud(np.nan_to_num(building_height_grid_ori, nan=10.0)).copy()
133
+ building_id_grid = np.flipud(building_id_grid_ori)
134
+
135
+ # Set border cells to 0 height
136
+ building_height_grid[0, :] = building_height_grid[-1, :] = building_height_grid[:, 0] = building_height_grid[:, -1] = 0
137
+ building_height_grid = apply_operation(building_height_grid, meshsize)
138
+
139
+ # Convert land cover if needed based on source
140
+ if land_cover_source == 'OpenStreetMap':
141
+ # OpenStreetMap uses Standard classification, just shift to 1-based
142
+ land_cover_grid_converted = land_cover_grid_ori + 1
143
+ else:
144
+ # All other sources need remapping to standard indices
145
+ land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=land_cover_source)
146
+
147
+ land_cover_grid = np.flipud(land_cover_grid_converted).copy()
148
+
149
+ # Dictionary mapping land cover types to vegetation codes
150
+ # Standard 1-based indices: 1=Bareland, 2=Rangeland, 3=Shrub, 4=Agriculture, 5=Tree,
151
+ # 6=Moss/lichen, 7=Wetland, 8=Mangrove, 9=Water, 10=Snow,
152
+ # 11=Developed, 12=Road, 13=Building, 14=NoData
153
+ veg_translation_dict = {
154
+ 1: '', # Bareland
155
+ 2: '0200XX', # Rangeland
156
+ 3: '0200H1', # Shrub
157
+ 4: '0200XX', # Agriculture land
158
+ 5: '', # Tree (handled separately as 3D vegetation)
159
+ 6: '0200XX', # Moss and lichen
160
+ 7: '0200XX', # Wet land
161
+ 8: '' # Mangroves
162
+ }
163
+ land_cover_veg_grid = translate_array(land_cover_grid, veg_translation_dict)
164
+
165
+ # Dictionary mapping land cover types to material codes
166
+ mat_translation_dict = {
167
+ 1: '000000', # Bareland
168
+ 2: '000000', # Rangeland
169
+ 3: '000000', # Shrub
170
+ 4: '000000', # Agriculture land
171
+ 5: '000000', # Tree
172
+ 6: '000000', # Moss and lichen
173
+ 7: '0200WW', # Wet land
174
+ 8: '0200WW', # Mangroves
175
+ 9: '0200WW', # Water
176
+ 10: '000000', # Snow and ice
177
+ 11: '0200PG', # Developed space
178
+ 12: '0200ST', # Road
179
+ 13: '000000', # Building
180
+ 14: '000000', # No Data
181
+ }
182
+ land_cover_mat_grid = translate_array(land_cover_grid, mat_translation_dict)
183
+
184
+ # Process canopy and DEM grids
185
+ canopy_height_grid = canopy_height_grid_ori.copy()
186
+ dem_grid = np.flipud(dem_grid_ori).copy() - np.min(dem_grid_ori)
187
+
188
+ return building_height_grid, building_id_grid, land_cover_veg_grid, land_cover_mat_grid, canopy_height_grid, dem_grid
189
+
190
+ def create_xml_content(building_height_grid, building_id_grid, land_cover_veg_grid, land_cover_mat_grid, canopy_height_grid, dem_grid, meshsize, rectangle_vertices, **kwargs):
191
+ """Create XML content for ENVI-met INX file.
192
+
193
+ This function generates the complete XML structure for an ENVI-met INX file,
194
+ including model metadata, geometry settings, and all required grid data.
195
+
196
+ Args:
197
+ building_height_grid (numpy.ndarray): Processed building heights
198
+ building_id_grid (numpy.ndarray): Processed building IDs
199
+ land_cover_veg_grid (numpy.ndarray): Vegetation codes grid
200
+ land_cover_mat_grid (numpy.ndarray): Material codes grid
201
+ canopy_height_grid (numpy.ndarray): Processed canopy heights
202
+ dem_grid (numpy.ndarray): Processed DEM
203
+ meshsize (float): Size of mesh cells in meters
204
+ rectangle_vertices (list): Vertices defining model area as [(lon, lat), ...]
205
+ **kwargs: Additional keyword arguments:
206
+ - author_name (str): Name of model author
207
+ - model_description (str): Description of model
208
+ - domain_building_max_height_ratio (float): Ratio of domain height to max building height
209
+ - useTelescoping_grid (bool): Whether to use telescoping grid
210
+ - verticalStretch (float): Vertical stretch factor
211
+ - startStretch (float): Height to start stretching
212
+ - min_grids_Z (int): Minimum vertical grid cells
213
+
214
+ Returns:
215
+ str: Complete XML content for INX file
216
+
217
+ Notes:
218
+ - Automatically determines location information from coordinates
219
+ - Handles both telescoping and uniform vertical grids
220
+ - Sets appropriate defaults for optional parameters
221
+ - Includes all required ENVI-met model settings
222
+ """
223
+ # XML template defining the structure of an ENVI-met INX file
224
+ xml_template = """<ENVI-MET_Datafile>
225
+ <Header>
226
+ <filetype>INPX ENVI-met Area Input File</filetype>
227
+ <version>440</version>
228
+ <revisiondate>7/5/2024 5:44:52 PM</revisiondate>
229
+ <remark>Created with SPACES 5.6.1</remark>
230
+ <checksum>0</checksum>
231
+ <encryptionlevel>0</encryptionlevel>
232
+ </Header>
233
+ <baseData>
234
+ <modelDescription> $modelDescription$ </modelDescription>
235
+ <modelAuthor> $modelAuthor$ </modelAuthor>
236
+ <modelcopyright> The creator or distributor is responsible for following Copyright Laws </modelcopyright>
237
+ </baseData>
238
+ <modelGeometry>
239
+ <grids-I> $grids-I$ </grids-I>
240
+ <grids-J> $grids-J$ </grids-J>
241
+ <grids-Z> $grids-Z$ </grids-Z>
242
+ <dx> $dx$ </dx>
243
+ <dy> $dy$ </dy>
244
+ <dz-base> $dz-base$ </dz-base>
245
+ <useTelescoping_grid> $useTelescoping_grid$ </useTelescoping_grid>
246
+ <useSplitting> 1 </useSplitting>
247
+ <verticalStretch> $verticalStretch$ </verticalStretch>
248
+ <startStretch> $startStretch$ </startStretch>
249
+ <has3DModel> 0 </has3DModel>
250
+ <isFull3DDesign> 0 </isFull3DDesign>
251
+ </modelGeometry>
252
+ <nestingArea>
253
+ <numberNestinggrids> 0 </numberNestinggrids>
254
+ <soilProfileA> 000000 </soilProfileA>
255
+ <soilProfileB> 000000 </soilProfileB>
256
+ </nestingArea>
257
+ <locationData>
258
+ <modelRotation> $modelRotation$ </modelRotation>
259
+ <projectionSystem> $projectionSystem$ </projectionSystem>
260
+ <UTMZone> 0 </UTMZone>
261
+ <realworldLowerLeft_X> 0.00000 </realworldLowerLeft_X>
262
+ <realworldLowerLeft_Y> 0.00000 </realworldLowerLeft_Y>
263
+ <locationName> $locationName$ </locationName>
264
+ <location_Longitude> $location_Longitude$ </location_Longitude>
265
+ <location_Latitude> $location_Latitude$ </location_Latitude>
266
+ <locationTimeZone_Name> $locationTimeZone_Name$ </locationTimeZone_Name>
267
+ <locationTimeZone_Longitude> $locationTimeZone_Longitude$ </locationTimeZone_Longitude>
268
+ </locationData>
269
+ <defaultSettings>
270
+ <commonWallMaterial> 000000 </commonWallMaterial>
271
+ <commonRoofMaterial> 000000 </commonRoofMaterial>
272
+ </defaultSettings>
273
+ <buildings2D>
274
+ <zTop type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
275
+ $zTop$
276
+ </zTop>
277
+ <zBottom type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
278
+ $zBottom$
279
+ </zBottom>
280
+ <buildingNr type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
281
+ $buildingNr$
282
+ </buildingNr>
283
+ <fixedheight type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
284
+ $fixedheight$
285
+ </fixedheight>
286
+ </buildings2D>
287
+ <simpleplants2D>
288
+ <ID_plants1D type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
289
+ $ID_plants1D$
290
+ </ID_plants1D>
291
+ </simpleplants2D>
292
+ $3Dplants$
293
+ <soils2D>
294
+ <ID_soilprofile type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
295
+ $ID_soilprofile$
296
+ </ID_soilprofile>
297
+ </soils2D>
298
+ <dem>
299
+ <DEMReference> $DEMReference$ </DEMReference>
300
+ <terrainheight type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
301
+ $terrainheight$
302
+ </terrainheight>
303
+ </dem>
304
+ <sources2D>
305
+ <ID_sources type="matrix-data" dataI="$grids-I$" dataJ="$grids-J$">
306
+ $ID_sources$
307
+ </ID_sources>
308
+ </sources2D>
309
+ </ENVI-MET_Datafile>"""
310
+
311
+ # Get location information based on rectangle vertices
312
+ city_country_name = get_city_country_name_from_rectangle(rectangle_vertices)
313
+
314
+ # Calculate center coordinates of the model area
315
+ longitudes = [coord[0] for coord in rectangle_vertices] # Changed order from lat to lon
316
+ latitudes = [coord[1] for coord in rectangle_vertices] # Changed order from lat to lon
317
+ center_lon = str(sum(longitudes) / len(longitudes)) # Changed order
318
+ center_lat = str(sum(latitudes) / len(latitudes)) # Changed order
319
+
320
+ timezone_info = get_timezone_info(rectangle_vertices)
321
+
322
+ # Set default values for optional parameters
323
+ author_name = kwargs.get('author_name')
324
+ if author_name is None:
325
+ author_name = "[Enter model author name]"
326
+ model_desctiption = kwargs.get('model_desctiption')
327
+ if model_desctiption is None:
328
+ model_desctiption = "[Enter model desctription]"
329
+
330
+ # Replace location-related placeholders in template
331
+ placeholders = {
332
+ "$modelDescription$": model_desctiption,
333
+ "$modelAuthor$": author_name,
334
+ "$modelRotation$": "0",
335
+ "$projectionSystem$": "GCS_WGS_1984",
336
+ "$locationName$": city_country_name,
337
+ "$location_Longitude$": center_lon,
338
+ "$location_Latitude$": center_lat,
339
+ "$locationTimeZone_Name$": timezone_info[0],
340
+ "$locationTimeZone_Longitude$": timezone_info[1],
341
+ }
342
+
343
+ # Ensure no None values are passed to replace()
344
+ for placeholder, value in placeholders.items():
345
+ if value is None:
346
+ print(f"Warning: {placeholder} is None, using fallback value")
347
+ if placeholder == "$locationName$":
348
+ value = "Unknown Location/ Unknown Country"
349
+ elif placeholder == "$locationTimeZone_Name$":
350
+ value = "UTC+00:00"
351
+ elif placeholder == "$locationTimeZone_Longitude$":
352
+ value = "0.00000"
353
+ elif placeholder == "$modelDescription$":
354
+ value = "[Enter model description]"
355
+ elif placeholder == "$modelAuthor$":
356
+ value = "[Enter model author name]"
357
+ else:
358
+ value = "Unknown"
359
+ xml_template = xml_template.replace(placeholder, str(value))
360
+
361
+ # Calculate building heights including terrain elevation
362
+ building_on_dem_grid = building_height_grid + dem_grid
363
+
364
+ # Configure vertical grid settings
365
+ domain_building_max_height_ratio = kwargs.get('domain_building_max_height_ratio')
366
+ if domain_building_max_height_ratio is None:
367
+ domain_building_max_height_ratio = 2
368
+
369
+ # Configure telescoping grid settings if enabled
370
+ useTelescoping_grid = kwargs.get('useTelescoping_grid')
371
+ if (useTelescoping_grid is None) or (useTelescoping_grid == False):
372
+ useTelescoping_grid = 0
373
+ verticalStretch = 0
374
+ startStretch = 0
375
+ else:
376
+ useTelescoping_grid = 1
377
+ verticalStretch = kwargs.get('verticalStretch')
378
+ if (verticalStretch is None):
379
+ verticalStretch = 20
380
+ startStretch = kwargs.get('startStretch')
381
+ if (startStretch is None):
382
+ startStretch = int(np.max(building_on_dem_grid)/meshsize + 0.5) * meshsize
383
+
384
+ # Set horizontal grid dimensions
385
+ grids_I, grids_J = building_height_grid.shape[1], building_height_grid.shape[0]
386
+
387
+ # Calculate vertical grid dimension based on building heights and telescoping settings
388
+ min_grids_Z = kwargs.get('min_grids_Z', 20)
389
+ if verticalStretch > 0:
390
+ # Calculate minimum number of cells needed to reach target height with telescoping
391
+ a = meshsize # First cell size
392
+ r = (100 + verticalStretch) / 100 # Growth ratio
393
+ S_target = (int(np.max(building_on_dem_grid)/meshsize + 0.5) * meshsize) * (domain_building_max_height_ratio - 1)
394
+ min_n = find_min_n(a, r, S_target, max_n=1000000)
395
+ if min_n is None:
396
+ # Fallback to non-telescoping grid if calculation fails
397
+ print("Warning: Telescoping grid calculation failed, using uniform grid")
398
+ grids_Z = max(int(np.max(building_on_dem_grid)/meshsize + 0.5) * domain_building_max_height_ratio, min_grids_Z)
399
+ else:
400
+ grids_Z_tent = int(np.max(building_on_dem_grid)/meshsize + 0.5) + min_n
401
+ if grids_Z_tent < min_grids_Z:
402
+ grids_Z = min_grids_Z
403
+ startStretch += (min_grids_Z - grids_Z)
404
+ else:
405
+ grids_Z = grids_Z_tent
406
+ else:
407
+ # Calculate vertical grid cells without telescoping
408
+ grids_Z = max(int(np.max(building_on_dem_grid)/meshsize + 0.5) * domain_building_max_height_ratio, min_grids_Z)
409
+
410
+ # Set grid cell sizes
411
+ dx, dy, dz_base = meshsize, meshsize, meshsize
412
+
413
+ # Replace grid-related placeholders
414
+ grid_placeholders = {
415
+ "$grids-I$": str(grids_I),
416
+ "$grids-J$": str(grids_J),
417
+ "$grids-Z$": str(grids_Z),
418
+ "$dx$": str(dx),
419
+ "$dy$": str(dy),
420
+ "$dz-base$": str(dz_base),
421
+ "$useTelescoping_grid$": str(useTelescoping_grid),
422
+ "$verticalStretch$": str(verticalStretch),
423
+ "$startStretch$": str(startStretch),
424
+ }
425
+
426
+ for placeholder, value in grid_placeholders.items():
427
+ xml_template = xml_template.replace(placeholder, value)
428
+
429
+ # Replace matrix data placeholders with actual grid data
430
+ xml_template = xml_template.replace("$zTop$", array_to_string(building_height_grid))
431
+ xml_template = xml_template.replace("$zBottom$", array_to_string_with_value(building_height_grid, '0'))
432
+ xml_template = xml_template.replace("$fixedheight$", array_to_string_with_value(building_height_grid, '0'))
433
+
434
+ # Process and add building numbers
435
+ building_nr_grid = group_and_label_cells(building_id_grid)
436
+ xml_template = xml_template.replace("$buildingNr$", array_to_string(building_nr_grid))
437
+
438
+ # Add vegetation data
439
+ xml_template = xml_template.replace("$ID_plants1D$", array_to_string(land_cover_veg_grid))
440
+
441
+ # Generate and add 3D plant data
442
+ tree_content = ""
443
+ for i in range(grids_I):
444
+ for j in range(grids_J):
445
+ canopy_height = int(canopy_height_grid[j, i] + 0.5)
446
+ # Only add trees where there are no buildings
447
+ if canopy_height_grid[j, i] > 0 and np.flipud(building_height_grid)[j, i]==0:
448
+ plantid = f'H{canopy_height:02d}W01'
449
+ tree_ij = f""" <3Dplants>
450
+ <rootcell_i> {i+1} </rootcell_i>
451
+ <rootcell_j> {j+1} </rootcell_j>
452
+ <rootcell_k> 0 </rootcell_k>
453
+ <plantID> {plantid} </plantID>
454
+ <name> .{plantid} </name>
455
+ <observe> 0 </observe>
456
+ </3Dplants>"""
457
+ tree_content += '\n' + tree_ij
458
+
459
+ # Add remaining data
460
+ xml_template = xml_template.replace("$3Dplants$", tree_content)
461
+ xml_template = xml_template.replace("$ID_soilprofile$", array_to_string(land_cover_mat_grid))
462
+ dem_grid = process_grid(building_nr_grid, dem_grid)
463
+ xml_template = xml_template.replace("$DEMReference$", '0')
464
+ xml_template = xml_template.replace("$terrainheight$", array_to_string_int(dem_grid))
465
+ xml_template = xml_template.replace("$ID_sources$", array_to_string_with_value(land_cover_mat_grid, ''))
466
+
467
+ return xml_template
468
+
469
+ def save_file(content, output_file_path):
470
+ """Save content to a file with UTF-8 encoding.
471
+
472
+ This function ensures consistent file encoding and error handling when
473
+ saving ENVI-met files.
474
+
475
+ Args:
476
+ content (str): String content to save
477
+ output_file_path (str): Path to save file to
478
+
479
+ Notes:
480
+ - Creates parent directories if they don't exist
481
+ - Uses UTF-8 encoding for compatibility
482
+ - Overwrites existing file if present
483
+ """
484
+ with open(output_file_path, 'w', encoding='utf-8') as file:
485
+ file.write(content)
486
+
487
+ def export_inx(city: VoxCity, output_directory: str, file_basename: str = 'voxcity', land_cover_source: str | None = None, **kwargs):
488
+ """Export model data to ENVI-met INX file format.
489
+
490
+ This is the main function for exporting voxel city data to ENVI-met format.
491
+ It coordinates the entire export process from grid preparation to file saving.
492
+
493
+ Args:
494
+ city (VoxCity): VoxCity instance to export
495
+ output_directory (str): Directory to save output
496
+ file_basename (str): Base filename (without extension)
497
+ land_cover_source (str | None): Optional override for land cover source; defaults to city.extras
498
+ **kwargs: Additional keyword arguments passed to create_xml_content()
499
+
500
+ Notes:
501
+ - Creates output directory if it doesn't exist
502
+ - Handles grid preparation and transformation
503
+ - Generates complete INX file with all required data
504
+ - Uses standardized file naming convention
505
+ """
506
+ # Resolve inputs from VoxCity
507
+ meshsize = float(city.voxels.meta.meshsize)
508
+ rectangle_vertices = city.extras.get("rectangle_vertices") or [(0.0, 0.0)] * 4
509
+ lc_source = land_cover_source or city.extras.get("land_cover_source", "Standard")
510
+
511
+ # Prepare grids
512
+ building_height_grid_inx, building_id_grid, land_cover_veg_grid_inx, land_cover_mat_grid_inx, canopy_height_grid_inx, dem_grid_inx = prepare_grids(
513
+ city.buildings.heights.copy(),
514
+ (city.buildings.ids if city.buildings.ids is not None else np.zeros_like(city.buildings.heights, dtype=int)).copy(),
515
+ (city.tree_canopy.top if city.tree_canopy is not None else np.zeros_like(city.land_cover.classes, dtype=float)).copy(),
516
+ city.land_cover.classes.copy(),
517
+ city.dem.elevation.copy(),
518
+ meshsize,
519
+ lc_source)
520
+
521
+ # Create XML content
522
+ xml_content = create_xml_content(building_height_grid_inx, building_id_grid, land_cover_veg_grid_inx, land_cover_mat_grid_inx, canopy_height_grid_inx, dem_grid_inx, meshsize, rectangle_vertices, **kwargs)
523
+
524
+ # Save the output
525
+ output_dir = output_directory or 'output'
526
+ os.makedirs(output_dir, exist_ok=True)
527
+ output_file_path = os.path.join(output_dir, f"{file_basename}.INX")
528
+ save_file(xml_content, output_file_path)
529
+
530
+
531
+ class EnvimetExporter:
532
+ """Exporter adapter to write a VoxCity model to ENVI-met INX format."""
533
+
534
+ def export(self, obj, output_directory: str, base_filename: str, **kwargs):
535
+ if not isinstance(obj, VoxCity):
536
+ raise TypeError("EnvimetExporter expects a VoxCity instance")
537
+ city: VoxCity = obj
538
+ export_inx(
539
+ city,
540
+ output_directory=output_directory,
541
+ file_basename=base_filename,
542
+ **kwargs,
543
+ )
544
+ return os.path.join(output_directory, f"{base_filename}.INX")
545
+
546
+ def generate_edb_file(**kwargs):
547
+ """Generate ENVI-met database file for 3D plants.
548
+
549
+ Creates a plant database file (EDB) containing definitions for trees of
550
+ different heights with customizable leaf area density profiles.
551
+
552
+ Args:
553
+ **kwargs: Keyword arguments:
554
+ - lad (float): Leaf area density in m²/m³ (default 1.0)
555
+ - trunk_height_ratio (float): Ratio of trunk height to total height
556
+ (default 11.76/19.98)
557
+
558
+ Notes:
559
+ - Generates plants for heights from 1-50m
560
+ - Uses standardized plant IDs in format 'HxxW01'
561
+ - Includes physical properties like wood density
562
+ - Sets seasonal variation profiles
563
+ - Creates complete ENVI-met plant database format
564
+ """
565
+
566
+ lad = kwargs.get('lad')
567
+ if lad is None:
568
+ lad=1.0
569
+
570
+ trunk_height_ratio = kwargs.get("trunk_height_ratio")
571
+ if trunk_height_ratio is None:
572
+ trunk_height_ratio = 11.76 / 19.98
573
+
574
+ # Create header with current timestamp
575
+ header = f'''<ENVI-MET_Datafile>
576
+ <Header>
577
+ <filetype>DATA</filetype>
578
+ <version>1</version>
579
+ <revisiondate>{datetime.datetime.now().strftime("%m/%d/%Y %I:%M:%S %p")}</revisiondate>
580
+ <remark>Envi-Data</remark>
581
+ <checksum>0</checksum>
582
+ <encryptionlevel>1699612</encryptionlevel>
583
+ </Header>
584
+ '''
585
+
586
+ footer = '</ENVI-MET_Datafile>'
587
+
588
+ # Generate plant definitions for heights 1-50m
589
+ plant3d_objects = []
590
+
591
+ for height in range(1, 51):
592
+ plant3d = f''' <PLANT3D>
593
+ <ID> H{height:02d}W01 </ID>
594
+ <Description> H{height:02d}W01 </Description>
595
+ <AlternativeName> Albero nuovo </AlternativeName>
596
+ <Planttype> 0 </Planttype>
597
+ <Leaftype> 1 </Leaftype>
598
+ <Albedo> 0.18000 </Albedo>
599
+ <Eps> 0.00000 </Eps>
600
+ <Transmittance> 0.30000 </Transmittance>
601
+ <isoprene> 12.00000 </isoprene>
602
+ <leafweigth> 100.00000 </leafweigth>
603
+ <rs_min> 0.00000 </rs_min>
604
+ <Height> {height:.5f} </Height>
605
+ <Width> 1.00000 </Width>
606
+ <Depth> {height * trunk_height_ratio:.5f} </Depth>
607
+ <RootDiameter> 1.00000 </RootDiameter>
608
+ <cellsize> 1.00000 </cellsize>
609
+ <xy_cells> 1 </xy_cells>
610
+ <z_cells> {height} </z_cells>
611
+ <scalefactor> 0.00000 </scalefactor>
612
+ <LAD-Profile type="sparematrix-3D" dataI="1" dataJ="1" zlayers="{height}" defaultValue="0.00000">
613
+ {generate_lad_profile(height, trunk_height_ratio, lad=str(lad))}
614
+ </LAD-Profile>
615
+ <RAD-Profile> 0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000,0.10000 </RAD-Profile>
616
+ <Root-Range-Profile> 1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000,1.00000 </Root-Range-Profile>
617
+ <Season-Profile> 0.30000,0.30000,0.30000,0.40000,0.70000,1.00000,1.00000,1.00000,0.80000,0.60000,0.30000,0.30000 </Season-Profile>
618
+ <Blossom-Profile> 0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000,0.00000 </Blossom-Profile>
619
+ <DensityWood> 690.00000 </DensityWood>
620
+ <YoungsModulus> 8770000896.00000 </YoungsModulus>
621
+ <YoungRatioRtoL> 0.12000 </YoungRatioRtoL>
622
+ <MORBranch> 65.00000 </MORBranch>
623
+ <MORConnection> 45.00000 </MORConnection>
624
+ <PlantGroup> 0 </PlantGroup>
625
+ <Color> 0 </Color>
626
+ <Group> </Group>
627
+ <Author> </Author>
628
+ <costs> 0.00000 </costs>
629
+ <ColorStem> 0 </ColorStem>
630
+ <ColorBlossom> 0 </ColorBlossom>
631
+ <BlossomRadius> 0.00000 </BlossomRadius>
632
+ <L-SystemBased> 0 </L-SystemBased>
633
+ <Axiom> V </Axiom>
634
+ <IterationDepth> 0 </IterationDepth>
635
+ <hasUserEdits> 0 </hasUserEdits>
636
+ <LADMatrix_generated> 0 </LADMatrix_generated>
637
+ <InitialSegmentLength> 0.00000 </InitialSegmentLength>
638
+ <SmallSegmentLength> 0.00000 </SmallSegmentLength>
639
+ <ChangeSegmentLength> 0.00000 </ChangeSegmentLength>
640
+ <SegmentResolution> 0.00000 </SegmentResolution>
641
+ <TurtleAngle> 0.00000 </TurtleAngle>
642
+ <RadiusOuterBranch> 0.00000 </RadiusOuterBranch>
643
+ <PipeFactor> 0.00000 </PipeFactor>
644
+ <LeafPosition> 0 </LeafPosition>
645
+ <LeafsPerNode> 0 </LeafsPerNode>
646
+ <LeafInternodeLength> 0.00000 </LeafInternodeLength>
647
+ <LeafMinSegmentOrder> 0 </LeafMinSegmentOrder>
648
+ <LeafWidth> 0.00000 </LeafWidth>
649
+ <LeafLength> 0.00000 </LeafLength>
650
+ <LeafSurface> 0.00000 </LeafSurface>
651
+ <PetioleAngle> 0.00000 </PetioleAngle>
652
+ <PetioleLength> 0.00000 </PetioleLength>
653
+ <LeafRotationalAngle> 0.00000 </LeafRotationalAngle>
654
+ <FactorHorizontal> 0.00000 </FactorHorizontal>
655
+ <TropismVector> 0.000000,0.000000,0.000000 </TropismVector>
656
+ <TropismElstaicity> 0.00000 </TropismElstaicity>
657
+ <SegmentRemovallist> </SegmentRemovallist>
658
+ <NrRules> 0 </NrRules>
659
+ <Rules_Variable> </Rules_Variable>
660
+ <Rules_Replacement> </Rules_Replacement>
661
+ <Rules_isConditional> </Rules_isConditional>
662
+ <Rules_Condition> </Rules_Condition>
663
+ <Rules_Remark> </Rules_Remark>
664
+ <TermLString> </TermLString>
665
+ <ApplyTermLString> 0 </ApplyTermLString>
666
+ </PLANT3D>
667
+ '''
668
+ plant3d_objects.append(plant3d)
669
+
670
+ content = header + ''.join(plant3d_objects) + footer
671
+
672
+ with open('projectdatabase.edb', 'w') as f:
673
+ f.write(content)
674
+
675
+ def generate_lad_profile(height, trunk_height_ratio, lad = '1.00000'):
676
+ """Generate leaf area density profile for a plant.
677
+
678
+ Creates a vertical profile of leaf area density (LAD) values for ENVI-met
679
+ plant definitions, accounting for trunk space and crown distribution.
680
+
681
+ Args:
682
+ height (int): Total height of plant in meters
683
+ trunk_height_ratio (float): Ratio of trunk height to total height
684
+ lad (str): Leaf area density value as string (default '1.00000')
685
+
686
+ Returns:
687
+ str: LAD profile data formatted for ENVI-met EDB file
688
+
689
+ Notes:
690
+ - LAD values start above trunk height
691
+ - Uses 5-space indentation for ENVI-met format
692
+ - Profile follows format: "z-level,x,y,LAD"
693
+ """
694
+ lad_profile = []
695
+ # Only add LAD values above trunk height
696
+ start = max(0, int(height * trunk_height_ratio))
697
+ for i in range(start, height):
698
+ lad_profile.append(f" 0,0,{i},{lad}")
699
+ return '\n'.join(lad_profile)
700
+
701
+ def find_min_n(a, r, S_target, max_n=1000000):
702
+ """Find minimum number of terms needed in geometric series to exceed target sum.
703
+
704
+ Used for calculating telescoping grid parameters to achieve desired domain height.
705
+ Solves for n in the equation: a(1-r^n)/(1-r) > S_target
706
+
707
+ Args:
708
+ a (float): First term of series (base cell size)
709
+ r (float): Common ratio (stretch factor)
710
+ S_target (float): Target sum to exceed (desired height)
711
+ max_n (int): Maximum number of terms to try (default 1000000)
712
+
713
+ Returns:
714
+ int or None: Minimum number of terms needed, or None if not possible within max_n
715
+
716
+ Notes:
717
+ - Handles special case of r=1 (arithmetic series)
718
+ - Protects against overflow with large exponents
719
+ - Returns None if solution not found within max_n terms
720
+ """
721
+ n = 1
722
+ while n <= max_n:
723
+ if r == 1:
724
+ S_n = a * n
725
+ else:
726
+ try:
727
+ S_n = a * (1 - r ** n) / (1 - r)
728
+ except OverflowError:
729
+ # Handle large exponents
730
+ S_n = float('inf') if r > 1 else 0
731
+ if (a > 0 and S_n > S_target) or (a < 0 and S_n < S_target):
732
+ return n
733
+ n += 1
729
734
  return None # Not possible within max_n terms