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