voxcity 0.3.1__py3-none-any.whl → 0.3.3__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.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

voxcity/file/geojson.py CHANGED
@@ -525,4 +525,75 @@ def find_building_containing_point(features, target_point):
525
525
  if polygon.contains(point):
526
526
  id_list.append(feature['properties']['id'])
527
527
 
528
- return id_list
528
+ return id_list
529
+
530
+ def get_buildings_in_drawn_polygon(building_geojson, drawn_polygon_vertices,
531
+ operation='within'):
532
+ """
533
+ Given a list of building footprints (in Lat-Lon) and a set of drawn polygon
534
+ vertices (also in Lat-Lon), return the building IDs that fall within or
535
+ intersect the drawn polygon.
536
+
537
+ Args:
538
+ building_geojson (list):
539
+ A list of GeoJSON features, each feature is a dict with:
540
+ {
541
+ "type": "Feature",
542
+ "geometry": {
543
+ "type": "Polygon",
544
+ "coordinates": [
545
+ [
546
+ [lat1, lon1], [lat2, lon2], ...
547
+ ]
548
+ ]
549
+ },
550
+ "properties": {
551
+ "id": ...
552
+ ...
553
+ }
554
+ }
555
+ Note: These coordinates are in (lat, lon) order, not standard (lon, lat).
556
+
557
+ drawn_polygon_vertices (list):
558
+ A list of (lat, lon) tuples representing the polygon drawn by the user.
559
+
560
+ operation (str):
561
+ Determines how to include buildings.
562
+ Use "intersect" to include buildings that intersect the drawn polygon.
563
+ Use "within" to include buildings that lie entirely within the drawn polygon.
564
+
565
+ Returns:
566
+ list:
567
+ A list of building IDs (strings or ints) that satisfy the condition.
568
+ """
569
+ # 1. Convert the user-drawn polygon vertices (lat, lon) into a Shapely Polygon.
570
+ # Shapely expects (x, y) = (longitude, latitude).
571
+ # So we'll do (lon, lat) for each vertex.
572
+ drawn_polygon_shapely = Polygon([(lon, lat) for (lat, lon) in drawn_polygon_vertices])
573
+
574
+ included_building_ids = []
575
+
576
+ # 2. Check each building in the GeoJSON
577
+ for feature in building_geojson:
578
+ # Skip any feature that is not Polygon
579
+ if feature['geometry']['type'] != 'Polygon':
580
+ continue
581
+
582
+ # Extract coordinates, which are in [ [lat, lon], [lat, lon], ... ]
583
+ coords = feature['geometry']['coordinates'][0]
584
+
585
+ # Create a Shapely polygon for the building
586
+ # Convert from (lat, lon) to (lon, lat)
587
+ building_polygon = Polygon([(lon, lat) for (lat, lon) in coords])
588
+
589
+ # 3. Depending on the operation, check the relationship
590
+ if operation == 'intersect':
591
+ if building_polygon.intersects(drawn_polygon_shapely):
592
+ included_building_ids.append(feature['properties'].get('id', None))
593
+ elif operation == 'within':
594
+ if building_polygon.within(drawn_polygon_shapely):
595
+ included_building_ids.append(feature['properties'].get('id', None))
596
+ else:
597
+ raise ValueError("operation must be 'intersect' or 'within'")
598
+
599
+ return included_building_ids
voxcity/geo/draw.py CHANGED
@@ -4,10 +4,11 @@ This module provides functions for drawing and manipulating rectangles on maps.
4
4
 
5
5
  import math
6
6
  from pyproj import Proj, transform
7
- from ipyleaflet import Map, DrawControl, Rectangle
7
+ from ipyleaflet import Map, DrawControl, Rectangle, Polygon as LeafletPolygon
8
8
  import ipyleaflet
9
9
  from geopy import distance
10
10
  from .utils import get_coordinates_from_cityname
11
+ import shapely.geometry as geom
11
12
 
12
13
  def rotate_rectangle(m, rectangle_vertices, angle):
13
14
  """
@@ -222,4 +223,113 @@ def center_location_map_cityname(cityname, east_west_length, north_south_length,
222
223
  # Register event handler for drawing actions
223
224
  draw_control.on_draw(handle_draw)
224
225
 
225
- return m, rectangle_vertices
226
+ return m, rectangle_vertices
227
+
228
+ def display_buildings_and_draw_polygon(building_geojson, zoom=17):
229
+ """
230
+ Displays building footprints (in Lat-Lon order) on an ipyleaflet map,
231
+ and allows the user to draw a polygon whose vertices are returned
232
+ in a Python list (also in Lat-Lon).
233
+
234
+ Args:
235
+ building_geojson (list): A list of GeoJSON features (Polygons),
236
+ BUT with coordinates in [lat, lon] order.
237
+ zoom (int): Initial zoom level for the map. Default=17.
238
+
239
+ Returns:
240
+ (map_object, drawn_polygon_vertices)
241
+ - map_object: ipyleaflet Map instance
242
+ - drawn_polygon_vertices: a Python list that gets updated whenever
243
+ a new polygon is created. The list is in (lat, lon) order.
244
+ """
245
+ # ---------------------------------------------------------
246
+ # 1. Determine a suitable map center via bounding box logic
247
+ # ---------------------------------------------------------
248
+ all_lats = []
249
+ all_lons = []
250
+ for feature in building_geojson:
251
+ # Handle only Polygons here; skip MultiPolygon if present
252
+ if feature['geometry']['type'] == 'Polygon':
253
+ # Coordinates in this data are [ [lat, lon], [lat, lon], ... ]
254
+ coords = feature['geometry']['coordinates'][0] # outer ring
255
+ all_lats.extend(pt[0] for pt in coords)
256
+ all_lons.extend(pt[1] for pt in coords)
257
+
258
+ if not all_lats or not all_lons:
259
+ # Fallback: If no footprints or invalid data, pick a default
260
+ center_lat, center_lon = 40.0, -100.0
261
+ else:
262
+ min_lat, max_lat = min(all_lats), max(all_lats)
263
+ min_lon, max_lon = min(all_lons), max(all_lons)
264
+ center_lat = (min_lat + max_lat) / 2
265
+ center_lon = (min_lon + max_lon) / 2
266
+
267
+ # Create the ipyleaflet map
268
+ m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
269
+
270
+ # -----------------------------------------
271
+ # 2. Add each building footprint to the map
272
+ # -----------------------------------------
273
+ for feature in building_geojson:
274
+ # Only handle simple Polygons
275
+ if feature['geometry']['type'] == 'Polygon':
276
+ coords = feature['geometry']['coordinates'][0]
277
+ # Because your data is already lat-lon, we do NOT swap:
278
+ lat_lon_coords = [(c[0], c[1]) for c in coords]
279
+
280
+ # Create the polygon layer
281
+ bldg_layer = LeafletPolygon(
282
+ locations=lat_lon_coords,
283
+ color="blue",
284
+ fill_color="blue",
285
+ fill_opacity=0.2,
286
+ weight=2
287
+ )
288
+ m.add_layer(bldg_layer)
289
+
290
+ # -----------------------------------------------------------------
291
+ # 3. Enable drawing of polygons, capturing the vertices in Lat-Lon
292
+ # -----------------------------------------------------------------
293
+ drawn_polygon_vertices = [] # We'll store the newly drawn polygon's vertices here (lat, lon).
294
+
295
+ draw_control = DrawControl(
296
+ polygon={
297
+ "shapeOptions": {
298
+ "color": "#6bc2e5",
299
+ "fillColor": "#6bc2e5",
300
+ "fillOpacity": 0.2
301
+ }
302
+ },
303
+ rectangle={}, # Disable rectangles (or enable if needed)
304
+ circle={}, # Disable circles
305
+ circlemarker={}, # Disable circlemarkers
306
+ polyline={}, # Disable polylines
307
+ marker={} # Disable markers
308
+ )
309
+
310
+ def handle_draw(self, action, geo_json):
311
+ """
312
+ Callback for whenever a shape is created or edited.
313
+ ipyleaflet's DrawControl returns standard GeoJSON (lon, lat).
314
+ We'll convert them to (lat, lon).
315
+ """
316
+ # Clear any previously stored vertices
317
+ drawn_polygon_vertices.clear()
318
+
319
+ if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
320
+ # The polygon’s first ring
321
+ coordinates = geo_json['geometry']['coordinates'][0]
322
+ print("Vertices of the drawn polygon (Lat-Lon):")
323
+
324
+ # By default, drawn polygon coords are [ [lon, lat], [lon, lat], ... ]
325
+ # The last coordinate repeats the first -> skip it with [:-1]
326
+ for coord in coordinates[:-1]:
327
+ lon = coord[0]
328
+ lat = coord[1]
329
+ drawn_polygon_vertices.append((lat, lon))
330
+ print(f" - (lat, lon) = ({lat}, {lon})")
331
+
332
+ draw_control.on_draw(handle_draw)
333
+ m.add_control(draw_control)
334
+
335
+ return m, drawn_polygon_vertices
voxcity/sim/solar.py CHANGED
@@ -16,10 +16,16 @@ def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_p
16
16
  """
17
17
  Compute a map of direct solar irradiation accounting for tree transmittance.
18
18
 
19
+ The function:
20
+ 1. Places observers at valid locations (empty voxels above ground)
21
+ 2. Casts rays from each observer in the sun direction
22
+ 3. Computes transmittance through trees using Beer-Lambert law
23
+ 4. Returns a 2D map of transmittance values
24
+
19
25
  Args:
20
26
  voxel_data (ndarray): 3D array of voxel values.
21
27
  sun_direction (tuple): Direction vector of the sun.
22
- view_height_voxel (int): Observer height in voxel units.
28
+ view_point_height (float): Observer height in meters.
23
29
  hit_values (tuple): Values considered non-obstacles if inclusion_mode=False.
24
30
  meshsize (float): Size of each voxel in meters.
25
31
  tree_k (float): Tree extinction coefficient.
@@ -27,7 +33,7 @@ def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_p
27
33
  inclusion_mode (bool): False here, meaning any voxel not in hit_values is an obstacle.
28
34
 
29
35
  Returns:
30
- ndarray: 2D array of transmittance values (0.0-1.0), NaN = invalid observer.
36
+ ndarray: 2D array of transmittance values (0.0-1.0), NaN = invalid observer position.
31
37
  """
32
38
 
33
39
  view_height_voxel = int(view_point_height / meshsize)
@@ -35,18 +41,22 @@ def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_p
35
41
  nx, ny, nz = voxel_data.shape
36
42
  irradiance_map = np.full((nx, ny), np.nan, dtype=np.float64)
37
43
 
38
- # Normalize sun direction
44
+ # Normalize sun direction vector for ray tracing
39
45
  sd = np.array(sun_direction, dtype=np.float64)
40
46
  sd_len = np.sqrt(sd[0]**2 + sd[1]**2 + sd[2]**2)
41
47
  if sd_len == 0.0:
42
48
  return np.flipud(irradiance_map)
43
49
  sd /= sd_len
44
50
 
51
+ # Process each x,y position in parallel
45
52
  for x in prange(nx):
46
53
  for y in range(ny):
47
54
  found_observer = False
55
+ # Search upward for valid observer position
48
56
  for z in range(1, nz):
57
+ # Check if current voxel is empty/tree and voxel below is solid
49
58
  if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
59
+ # Skip if standing on building/vegetation/water
50
60
  if voxel_data[x, y, z - 1] in (-30, -3, -2):
51
61
  irradiance_map[x, y] = np.nan
52
62
  found_observer = True
@@ -62,12 +72,43 @@ def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_p
62
72
  if not found_observer:
63
73
  irradiance_map[x, y] = np.nan
64
74
 
75
+ # Flip map vertically to match visualization conventions
65
76
  return np.flipud(irradiance_map)
66
77
 
67
78
  def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, elevation_degrees,
68
79
  direct_normal_irradiance, show_plot=False, **kwargs):
69
80
  """
70
81
  Compute direct solar irradiance map with tree transmittance.
82
+
83
+ The function:
84
+ 1. Converts sun angles to direction vector
85
+ 2. Computes binary transmittance map
86
+ 3. Scales by direct normal irradiance and sun elevation
87
+ 4. Optionally visualizes and exports results
88
+
89
+ Args:
90
+ voxel_data (ndarray): 3D array of voxel values.
91
+ meshsize (float): Size of each voxel in meters.
92
+ azimuth_degrees_ori (float): Sun azimuth angle in degrees (0° = North, 90° = East).
93
+ elevation_degrees (float): Sun elevation angle in degrees above horizon.
94
+ direct_normal_irradiance (float): Direct normal irradiance in W/m².
95
+ show_plot (bool): Whether to display visualization.
96
+ **kwargs: Additional arguments including:
97
+ - view_point_height (float): Observer height in meters (default: 1.5)
98
+ - colormap (str): Matplotlib colormap name (default: 'magma')
99
+ - vmin (float): Minimum value for colormap
100
+ - vmax (float): Maximum value for colormap
101
+ - tree_k (float): Tree extinction coefficient (default: 0.6)
102
+ - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
103
+ - obj_export (bool): Whether to export as OBJ file
104
+ - output_directory (str): Directory for OBJ export
105
+ - output_file_name (str): Filename for OBJ export
106
+ - dem_grid (ndarray): DEM grid for OBJ export
107
+ - num_colors (int): Number of colors for OBJ export
108
+ - alpha (float): Alpha value for OBJ export
109
+
110
+ Returns:
111
+ ndarray: 2D array of direct solar irradiance values (W/m²).
71
112
  """
72
113
  view_point_height = kwargs.get("view_point_height", 1.5)
73
114
  colormap = kwargs.get("colormap", 'magma')
@@ -78,7 +119,8 @@ def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, e
78
119
  tree_k = kwargs.get("tree_k", 0.6)
79
120
  tree_lad = kwargs.get("tree_lad", 1.0)
80
121
 
81
- # Convert angles to direction
122
+ # Convert sun angles to direction vector
123
+ # Note: azimuth is adjusted by 180° to match coordinate system
82
124
  azimuth_degrees = 180 - azimuth_degrees_ori
83
125
  azimuth_radians = np.deg2rad(azimuth_degrees)
84
126
  elevation_radians = np.deg2rad(elevation_degrees)
@@ -91,14 +133,17 @@ def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, e
91
133
  hit_values = (0,)
92
134
  inclusion_mode = False
93
135
 
136
+ # Compute transmittance map
94
137
  transmittance_map = compute_direct_solar_irradiance_map_binary(
95
138
  voxel_data, sun_direction, view_point_height, hit_values,
96
139
  meshsize, tree_k, tree_lad, inclusion_mode
97
140
  )
98
141
 
142
+ # Scale by direct normal irradiance and sun elevation
99
143
  sin_elev = dz
100
144
  direct_map = transmittance_map * direct_normal_irradiance * sin_elev
101
145
 
146
+ # Optional visualization
102
147
  if show_plot:
103
148
  cmap = plt.cm.get_cmap(colormap).copy()
104
149
  cmap.set_bad(color='lightgray')
@@ -135,6 +180,33 @@ def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, e
135
180
  def get_diffuse_solar_irradiance_map(voxel_data, meshsize, diffuse_irradiance=1.0, show_plot=False, **kwargs):
136
181
  """
137
182
  Compute diffuse solar irradiance map using the Sky View Factor (SVF) with tree transmittance.
183
+
184
+ The function:
185
+ 1. Computes SVF map accounting for tree transmittance
186
+ 2. Scales SVF by diffuse horizontal irradiance
187
+ 3. Optionally visualizes and exports results
188
+
189
+ Args:
190
+ voxel_data (ndarray): 3D array of voxel values.
191
+ meshsize (float): Size of each voxel in meters.
192
+ diffuse_irradiance (float): Diffuse horizontal irradiance in W/m².
193
+ show_plot (bool): Whether to display visualization.
194
+ **kwargs: Additional arguments including:
195
+ - view_point_height (float): Observer height in meters (default: 1.5)
196
+ - colormap (str): Matplotlib colormap name (default: 'magma')
197
+ - vmin (float): Minimum value for colormap
198
+ - vmax (float): Maximum value for colormap
199
+ - tree_k (float): Tree extinction coefficient
200
+ - tree_lad (float): Leaf area density in m^-1
201
+ - obj_export (bool): Whether to export as OBJ file
202
+ - output_directory (str): Directory for OBJ export
203
+ - output_file_name (str): Filename for OBJ export
204
+ - dem_grid (ndarray): DEM grid for OBJ export
205
+ - num_colors (int): Number of colors for OBJ export
206
+ - alpha (float): Alpha value for OBJ export
207
+
208
+ Returns:
209
+ ndarray: 2D array of diffuse solar irradiance values (W/m²).
138
210
  """
139
211
 
140
212
  view_point_height = kwargs.get("view_point_height", 1.5)
@@ -152,6 +224,7 @@ def get_diffuse_solar_irradiance_map(voxel_data, meshsize, diffuse_irradiance=1.
152
224
  SVF_map = get_sky_view_factor_map(voxel_data, meshsize, **svf_kwargs)
153
225
  diffuse_map = SVF_map * diffuse_irradiance
154
226
 
227
+ # Optional visualization
155
228
  if show_plot:
156
229
  vmin = kwargs.get("vmin", 0.0)
157
230
  vmax = kwargs.get("vmax", diffuse_irradiance)
@@ -201,18 +274,35 @@ def get_global_solar_irradiance_map(
201
274
  """
202
275
  Compute global solar irradiance (direct + diffuse) on a horizontal plane at each valid observer location.
203
276
 
204
- No mode/hit_values/inclusion_mode needed. Uses the updated direct and diffuse functions.
277
+ The function:
278
+ 1. Computes direct solar irradiance map
279
+ 2. Computes diffuse solar irradiance map
280
+ 3. Combines maps and optionally visualizes/exports results
205
281
 
206
282
  Args:
207
283
  voxel_data (ndarray): 3D voxel array.
208
284
  meshsize (float): Voxel size in meters.
209
- azimuth_degrees (float): Sun azimuth angle in degrees.
210
- elevation_degrees (float): Sun elevation angle in degrees.
211
- direct_normal_irradiance (float): DNI in W/m².
212
- diffuse_irradiance (float): Diffuse irradiance in W/m².
285
+ azimuth_degrees (float): Sun azimuth angle in degrees (0° = North, 90° = East).
286
+ elevation_degrees (float): Sun elevation angle in degrees above horizon.
287
+ direct_normal_irradiance (float): Direct normal irradiance in W/m².
288
+ diffuse_irradiance (float): Diffuse horizontal irradiance in W/m².
289
+ show_plot (bool): Whether to display visualization.
290
+ **kwargs: Additional arguments including:
291
+ - view_point_height (float): Observer height in meters (default: 1.5)
292
+ - colormap (str): Matplotlib colormap name (default: 'magma')
293
+ - vmin (float): Minimum value for colormap
294
+ - vmax (float): Maximum value for colormap
295
+ - tree_k (float): Tree extinction coefficient
296
+ - tree_lad (float): Leaf area density in m^-1
297
+ - obj_export (bool): Whether to export as OBJ file
298
+ - output_directory (str): Directory for OBJ export
299
+ - output_file_name (str): Filename for OBJ export
300
+ - dem_grid (ndarray): DEM grid for OBJ export
301
+ - num_colors (int): Number of colors for OBJ export
302
+ - alpha (float): Alpha value for OBJ export
213
303
 
214
304
  Returns:
215
- ndarray: 2D array of global solar irradiance (W/m²).
305
+ ndarray: 2D array of global solar irradiance values (W/m²).
216
306
  """
217
307
 
218
308
  colormap = kwargs.get("colormap", 'magma')
@@ -242,12 +332,13 @@ def get_global_solar_irradiance_map(
242
332
  **direct_diffuse_kwargs
243
333
  )
244
334
 
245
- # Sum the two
335
+ # Sum the two components
246
336
  global_map = direct_map + diffuse_map
247
337
 
248
338
  vmin = kwargs.get("vmin", np.nanmin(global_map))
249
339
  vmax = kwargs.get("vmax", np.nanmax(global_map))
250
340
 
341
+ # Optional visualization
251
342
  if show_plot:
252
343
  cmap = plt.cm.get_cmap(colormap).copy()
253
344
  cmap.set_bad(color='lightgray')
@@ -286,7 +377,19 @@ def get_global_solar_irradiance_map(
286
377
  def get_solar_positions_astral(times, lat, lon):
287
378
  """
288
379
  Compute solar azimuth and elevation using Astral for given times and location.
289
- Times must be timezone-aware.
380
+
381
+ The function:
382
+ 1. Creates an Astral observer at the specified location
383
+ 2. Computes sun position for each timestamp
384
+ 3. Returns DataFrame with azimuth and elevation angles
385
+
386
+ Args:
387
+ times (DatetimeIndex): Array of timezone-aware datetime objects.
388
+ lat (float): Latitude in degrees.
389
+ lon (float): Longitude in degrees.
390
+
391
+ Returns:
392
+ DataFrame: DataFrame with columns 'azimuth' and 'elevation' containing solar positions.
290
393
  """
291
394
  observer = Observer(latitude=lat, longitude=lon)
292
395
  df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
@@ -303,70 +406,55 @@ def get_solar_positions_astral(times, lat, lon):
303
406
  def get_cumulative_global_solar_irradiance(
304
407
  voxel_data,
305
408
  meshsize,
306
- start_time,
307
- end_time,
409
+ df, lat, lon, tz,
308
410
  direct_normal_irradiance_scaling=1.0,
309
411
  diffuse_irradiance_scaling=1.0,
310
412
  **kwargs
311
413
  ):
312
414
  """
313
- Compute cumulative global solar irradiance over a specified period using data from an EPW file,
314
- accounting for tree transmittance.
415
+ Compute cumulative global solar irradiance over a specified period using data from an EPW file.
416
+
417
+ The function:
418
+ 1. Filters EPW data for specified time period
419
+ 2. Computes sun positions for each timestep
420
+ 3. Calculates and accumulates global irradiance maps
421
+ 4. Handles tree transmittance and visualization
315
422
 
316
423
  Args:
317
424
  voxel_data (ndarray): 3D array of voxel values.
318
425
  meshsize (float): Size of each voxel in meters.
319
- start_time (str): Start time in format 'MM-DD HH:MM:SS' (no year).
320
- end_time (str): End time in format 'MM-DD HH:MM:SS' (no year).
321
- direct_normal_irradiance_scaling (float): Scaling factor for DNI.
322
- diffuse_irradiance_scaling (float): Scaling factor for DHI.
426
+ df (DataFrame): EPW weather data.
427
+ lat (float): Latitude in degrees.
428
+ lon (float): Longitude in degrees.
429
+ tz (float): Timezone offset in hours.
430
+ direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
431
+ diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
323
432
  **kwargs: Additional arguments including:
324
- - view_point_height (float): Observer height in meters
325
- - tree_k (float): Tree extinction coefficient (default: 0.5)
326
- - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
327
- - download_nearest_epw (bool): Whether to download nearest EPW file
328
- - epw_file_path (str): Path to EPW file
433
+ - view_point_height (float): Observer height in meters (default: 1.5)
434
+ - start_time (str): Start time in format 'MM-DD HH:MM:SS'
435
+ - end_time (str): End time in format 'MM-DD HH:MM:SS'
436
+ - tree_k (float): Tree extinction coefficient
437
+ - tree_lad (float): Leaf area density in m^-1
329
438
  - show_plot (bool): Whether to show final plot
330
439
  - show_each_timestep (bool): Whether to show plots for each timestep
440
+ - colormap (str): Matplotlib colormap name
441
+ - vmin (float): Minimum value for colormap
442
+ - vmax (float): Maximum value for colormap
443
+ - obj_export (bool): Whether to export as OBJ file
444
+ - output_directory (str): Directory for OBJ export
445
+ - output_file_name (str): Filename for OBJ export
446
+ - dem_grid (ndarray): DEM grid for OBJ export
447
+ - num_colors (int): Number of colors for OBJ export
448
+ - alpha (float): Alpha value for OBJ export
331
449
 
332
450
  Returns:
333
- ndarray: 2D array of cumulative global solar irradiance (W/m²·hour).
451
+ ndarray: 2D array of cumulative global solar irradiance values (W/m²·hour).
334
452
  """
335
453
  view_point_height = kwargs.get("view_point_height", 1.5)
336
454
  colormap = kwargs.get("colormap", 'magma')
455
+ start_time = kwargs.get("start_time", "01-01 05:00:00")
456
+ end_time = kwargs.get("end_time", "01-01 20:00:00")
337
457
 
338
- # Get EPW file
339
- download_nearest_epw = kwargs.get("download_nearest_epw", False)
340
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
341
- epw_file_path = kwargs.get("epw_file_path", None)
342
- if download_nearest_epw:
343
- if rectangle_vertices is None:
344
- print("rectangle_vertices is required to download nearest EPW file")
345
- return None
346
- else:
347
- # Calculate center point of rectangle
348
- lats = [coord[0] for coord in rectangle_vertices]
349
- lons = [coord[1] for coord in rectangle_vertices]
350
- center_lat = (min(lats) + max(lats)) / 2
351
- center_lon = (min(lons) + max(lons)) / 2
352
- target_point = (center_lat, center_lon)
353
-
354
- # Optional: specify maximum distance in kilometers
355
- max_distance = 100 # None for no limit
356
-
357
- output_dir = kwargs.get("output_dir", "output")
358
-
359
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
360
- latitude=center_lat,
361
- longitude=center_lon,
362
- output_dir=output_dir,
363
- max_distance=max_distance,
364
- extract_zip=True,
365
- load_data=True
366
- )
367
-
368
- # Read EPW data
369
- df, lat, lon, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
370
458
  if df.empty:
371
459
  raise ValueError("No data in EPW file.")
372
460
 
@@ -377,20 +465,23 @@ def get_cumulative_global_solar_irradiance(
377
465
  except ValueError as ve:
378
466
  raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
379
467
 
380
- # Add hour of year column and filter data as before...
468
+ # Add hour of year column and filter data
381
469
  df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
382
470
 
471
+ # Convert dates to day of year and hour
383
472
  start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
384
473
  end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
385
474
 
386
475
  start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
387
476
  end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
388
477
 
478
+ # Handle period crossing year boundary
389
479
  if start_hour <= end_hour:
390
480
  df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
391
481
  else:
392
482
  df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
393
483
 
484
+ # Filter by minutes within start/end hours
394
485
  df_period = df_period[
395
486
  ((df_period.index.hour != start_dt.hour) | (df_period.index.minute >= start_dt.minute)) &
396
487
  ((df_period.index.hour != end_dt.hour) | (df_period.index.minute <= end_dt.minute))
@@ -399,14 +490,14 @@ def get_cumulative_global_solar_irradiance(
399
490
  if df_period.empty:
400
491
  raise ValueError("No EPW data in the specified period.")
401
492
 
402
- # Prepare timezone conversion
493
+ # Handle timezone conversion
403
494
  offset_minutes = int(tz * 60)
404
495
  local_tz = pytz.FixedOffset(offset_minutes)
405
496
  df_period_local = df_period.copy()
406
497
  df_period_local.index = df_period_local.index.tz_localize(local_tz)
407
498
  df_period_utc = df_period_local.tz_convert(pytz.UTC)
408
499
 
409
- # Compute solar positions
500
+ # Compute solar positions for period
410
501
  solar_positions = get_solar_positions_astral(df_period_utc.index, lat, lon)
411
502
 
412
503
  # Create kwargs for diffuse calculation
@@ -424,7 +515,7 @@ def get_cumulative_global_solar_irradiance(
424
515
  **diffuse_kwargs
425
516
  )
426
517
 
427
- # Initialize maps
518
+ # Initialize accumulation maps
428
519
  cumulative_map = np.zeros((voxel_data.shape[0], voxel_data.shape[1]))
429
520
  mask_map = np.ones((voxel_data.shape[0], voxel_data.shape[1]), dtype=bool)
430
521
 
@@ -436,13 +527,14 @@ def get_cumulative_global_solar_irradiance(
436
527
  'obj_export': False
437
528
  })
438
529
 
439
- # Iterate through each time step
530
+ # Process each timestep
440
531
  for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
532
+ # Get scaled irradiance values
441
533
  DNI = row['DNI'] * direct_normal_irradiance_scaling
442
534
  DHI = row['DHI'] * diffuse_irradiance_scaling
443
535
  time_local = df_period_local.index[idx]
444
536
 
445
- # Get solar position
537
+ # Get solar position for timestep
446
538
  solpos = solar_positions.loc[time_utc]
447
539
  azimuth_degrees = solpos['azimuth']
448
540
  elevation_degrees = solpos['elevation']
@@ -457,13 +549,13 @@ def get_cumulative_global_solar_irradiance(
457
549
  **direct_kwargs
458
550
  )
459
551
 
460
- # Scale base_diffuse_map by actual DHI
552
+ # Scale base diffuse map by actual DHI
461
553
  diffuse_map = base_diffuse_map * DHI
462
554
 
463
- # Combine direct and diffuse
555
+ # Combine direct and diffuse components
464
556
  global_map = direct_map + diffuse_map
465
557
 
466
- # Update mask_map
558
+ # Update valid pixel mask
467
559
  mask_map &= ~np.isnan(global_map)
468
560
 
469
561
  # Replace NaN with 0 for accumulation
@@ -484,7 +576,7 @@ def get_cumulative_global_solar_irradiance(
484
576
  plt.colorbar(label='Global Solar Irradiance (W/m²)')
485
577
  plt.show()
486
578
 
487
- # Apply mask
579
+ # Apply mask to final result
488
580
  cumulative_map[~mask_map] = np.nan
489
581
 
490
582
  # Final visualization
@@ -526,4 +618,136 @@ def get_cumulative_global_solar_irradiance(
526
618
  vmax=vmax
527
619
  )
528
620
 
529
- return cumulative_map
621
+ return cumulative_map
622
+
623
+ def get_global_solar_irradiance_using_epw(
624
+ voxel_data,
625
+ meshsize,
626
+ calc_type='instantaneous',
627
+ direct_normal_irradiance_scaling=1.0,
628
+ diffuse_irradiance_scaling=1.0,
629
+ **kwargs
630
+ ):
631
+ """
632
+ Compute global solar irradiance using EPW weather data, either for a single time or cumulatively over a period.
633
+
634
+ The function:
635
+ 1. Optionally downloads and reads EPW weather data
636
+ 2. Handles timezone conversions and solar position calculations
637
+ 3. Computes either instantaneous or cumulative irradiance maps
638
+ 4. Supports visualization and export options
639
+
640
+ Args:
641
+ voxel_data (ndarray): 3D array of voxel values.
642
+ meshsize (float): Size of each voxel in meters.
643
+ calc_type (str): 'instantaneous' or 'cumulative'.
644
+ direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
645
+ diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
646
+ **kwargs: Additional arguments including:
647
+ - download_nearest_epw (bool): Whether to download nearest EPW file
648
+ - epw_file_path (str): Path to EPW file
649
+ - rectangle_vertices (list): List of (lat,lon) coordinates for EPW download
650
+ - output_dir (str): Directory for EPW download
651
+ - calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
652
+ - start_time (str): Start time for cumulative calculation
653
+ - end_time (str): End time for cumulative calculation
654
+ - view_point_height (float): Observer height in meters
655
+ - tree_k (float): Tree extinction coefficient
656
+ - tree_lad (float): Leaf area density in m^-1
657
+ - show_plot (bool): Whether to show visualization
658
+ - show_each_timestep (bool): Whether to show timestep plots
659
+ - colormap (str): Matplotlib colormap name
660
+ - obj_export (bool): Whether to export as OBJ file
661
+
662
+ Returns:
663
+ ndarray: 2D array of solar irradiance values (W/m²).
664
+ """
665
+ view_point_height = kwargs.get("view_point_height", 1.5)
666
+ colormap = kwargs.get("colormap", 'magma')
667
+
668
+ # Get EPW file
669
+ download_nearest_epw = kwargs.get("download_nearest_epw", False)
670
+ rectangle_vertices = kwargs.get("rectangle_vertices", None)
671
+ epw_file_path = kwargs.get("epw_file_path", None)
672
+ if download_nearest_epw:
673
+ if rectangle_vertices is None:
674
+ print("rectangle_vertices is required to download nearest EPW file")
675
+ return None
676
+ else:
677
+ # Calculate center point of rectangle
678
+ lats = [coord[0] for coord in rectangle_vertices]
679
+ lons = [coord[1] for coord in rectangle_vertices]
680
+ center_lat = (min(lats) + max(lats)) / 2
681
+ center_lon = (min(lons) + max(lons)) / 2
682
+ target_point = (center_lat, center_lon)
683
+
684
+ # Optional: specify maximum distance in kilometers
685
+ max_distance = 100 # None for no limit
686
+
687
+ output_dir = kwargs.get("output_dir", "output")
688
+
689
+ epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
690
+ latitude=center_lat,
691
+ longitude=center_lon,
692
+ output_dir=output_dir,
693
+ max_distance=max_distance,
694
+ extract_zip=True,
695
+ load_data=True
696
+ )
697
+
698
+ # Read EPW data
699
+ df, lat, lon, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
700
+ if df.empty:
701
+ raise ValueError("No data in EPW file.")
702
+
703
+ if calc_type == 'instantaneous':
704
+ if df.empty:
705
+ raise ValueError("No data in EPW file.")
706
+
707
+ calc_time = kwargs.get("calc_time", "01-01 12:00:00")
708
+
709
+ # Parse start and end times without year
710
+ try:
711
+ calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
712
+ except ValueError as ve:
713
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
714
+
715
+ df_period = df[
716
+ (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
717
+ ]
718
+
719
+ if df_period.empty:
720
+ raise ValueError("No EPW data at the specified time.")
721
+
722
+ # Prepare timezone conversion
723
+ offset_minutes = int(tz * 60)
724
+ local_tz = pytz.FixedOffset(offset_minutes)
725
+ df_period_local = df_period.copy()
726
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
727
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
728
+
729
+ # Compute solar positions
730
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lat, lon)
731
+ direct_normal_irradiance = df_period_utc.iloc[0]['DNI']
732
+ diffuse_irradiance = df_period_utc.iloc[0]['DHI']
733
+ azimuth_degrees = solar_positions.iloc[0]['azimuth']
734
+ elevation_degrees = solar_positions.iloc[0]['elevation']
735
+ solar_map = get_global_solar_irradiance_map(
736
+ voxel_data, # 3D voxel grid representing the urban environment
737
+ meshsize, # Size of each grid cell in meters
738
+ azimuth_degrees, # Sun's azimuth angle
739
+ elevation_degrees, # Sun's elevation angle
740
+ direct_normal_irradiance, # Direct Normal Irradiance value
741
+ diffuse_irradiance, # Diffuse irradiance value
742
+ show_plot=True, # Display visualization of results
743
+ **kwargs
744
+ )
745
+ if calc_type == 'cumulative':
746
+ solar_map = get_cumulative_global_solar_irradiance(
747
+ voxel_data,
748
+ meshsize,
749
+ df, lat, lon, tz,
750
+ **kwargs
751
+ )
752
+
753
+ return solar_map
voxcity/sim/view.py CHANGED
@@ -2,15 +2,43 @@
2
2
 
3
3
  This module provides functionality to compute and visualize:
4
4
  - Green View Index (GVI): Measures visibility of green elements like trees and vegetation
5
- - Sky View Index (SVI): Measures visibility of open sky from street level
5
+ - Sky View Index (SVI): Measures visibility of open sky from street level
6
+ - Sky View Factor (SVF): Measures the ratio of visible sky hemisphere to total hemisphere
6
7
  - Landmark Visibility: Measures visibility of specified landmark buildings from different locations
7
8
 
8
9
  The module uses optimized ray tracing techniques with Numba JIT compilation for efficient computation.
9
10
  Key features:
10
11
  - Generic ray tracing framework that can be customized for different view indices
11
12
  - Parallel processing for fast computation of view maps
13
+ - Tree transmittance modeling using Beer-Lambert law
12
14
  - Visualization tools including matplotlib plots and OBJ exports
13
15
  - Support for both inclusion and exclusion based visibility checks
16
+
17
+ The module provides several key functions:
18
+ - trace_ray_generic(): Core ray tracing function that handles tree transmittance
19
+ - compute_vi_generic(): Computes view indices by casting rays in specified directions
20
+ - compute_vi_map_generic(): Generates 2D maps of view indices
21
+ - get_view_index(): High-level function to compute various view indices
22
+ - compute_landmark_visibility(): Computes visibility of landmark buildings
23
+ - get_sky_view_factor_map(): Computes sky view factor maps
24
+
25
+ The module uses a voxel-based representation where:
26
+ - Empty space is represented by 0
27
+ - Trees are represented by -2
28
+ - Buildings are represented by -3
29
+ - Other values can be used for different features
30
+
31
+ Tree transmittance is modeled using the Beer-Lambert law with configurable parameters:
32
+ - tree_k: Static extinction coefficient (default 0.6)
33
+ - tree_lad: Leaf area density in m^-1 (default 1.0)
34
+
35
+ Additional implementation details:
36
+ - Uses DDA (Digital Differential Analyzer) algorithm for efficient ray traversal
37
+ - Handles edge cases like zero-length rays and division by zero
38
+ - Supports early exit optimizations for performance
39
+ - Provides flexible observer placement rules
40
+ - Includes comprehensive error checking and validation
41
+ - Allows customization of visualization parameters
14
42
  """
15
43
 
16
44
  import numpy as np
@@ -18,20 +46,31 @@ import matplotlib.pyplot as plt
18
46
  import matplotlib.patches as mpatches
19
47
  from numba import njit, prange
20
48
 
21
- from ..file.geojson import find_building_containing_point
49
+ from ..file.geojson import find_building_containing_point, get_buildings_in_drawn_polygon
22
50
  from ..file.obj import grid_to_obj, export_obj
23
51
 
24
52
  @njit
25
53
  def calculate_transmittance(length, tree_k=0.6, tree_lad=1.0):
26
54
  """Calculate tree transmittance using the Beer-Lambert law.
27
55
 
56
+ Uses the Beer-Lambert law to model light attenuation through tree canopy:
57
+ transmittance = exp(-k * LAD * L)
58
+ where:
59
+ - k is the extinction coefficient
60
+ - LAD is the leaf area density
61
+ - L is the path length through the canopy
62
+
28
63
  Args:
29
64
  length (float): Path length through tree voxel in meters
30
- tree_k (float): Static extinction coefficient (default: 0.5)
65
+ tree_k (float): Static extinction coefficient (default: 0.6)
66
+ Controls overall light attenuation strength
31
67
  tree_lad (float): Leaf area density in m^-1 (default: 1.0)
68
+ Higher values = denser foliage = more attenuation
32
69
 
33
70
  Returns:
34
71
  float: Transmittance value between 0 and 1
72
+ 1.0 = fully transparent
73
+ 0.0 = fully opaque
35
74
  """
36
75
  return np.exp(-tree_k * tree_lad * length)
37
76
 
@@ -39,9 +78,34 @@ def calculate_transmittance(length, tree_k=0.6, tree_lad=1.0):
39
78
  def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_k, tree_lad, inclusion_mode=True):
40
79
  """Trace a ray through a voxel grid and check for hits with specified values.
41
80
 
42
- For tree voxels (-2):
43
- - If -2 in hit_values: counts obstruction (1 - transmittance) as hit contribution
44
- - If -2 not in hit_values: applies transmittance normally
81
+ Uses DDA (Digital Differential Analyzer) algorithm for efficient ray traversal.
82
+ Handles tree transmittance using Beer-Lambert law.
83
+
84
+ The DDA algorithm:
85
+ 1. Initializes ray at origin voxel
86
+ 2. Calculates distances to next voxel boundaries in each direction
87
+ 3. Steps to next voxel by choosing smallest distance
88
+ 4. Repeats until hit or out of bounds
89
+
90
+ Tree transmittance:
91
+ - When ray passes through tree voxels (-2), transmittance is accumulated
92
+ - Uses Beer-Lambert law with configurable extinction coefficient and leaf area density
93
+ - Ray is considered blocked if cumulative transmittance falls below 0.01
94
+
95
+ Args:
96
+ voxel_data (ndarray): 3D array of voxel values
97
+ origin (ndarray): Starting point (x,y,z) of ray in voxel coordinates
98
+ direction (ndarray): Direction vector of ray (will be normalized)
99
+ hit_values (tuple): Values to check for hits
100
+ meshsize (float): Size of each voxel in meters
101
+ tree_k (float): Tree extinction coefficient
102
+ tree_lad (float): Leaf area density in m^-1
103
+ inclusion_mode (bool): If True, hit_values are hits. If False, hit_values are allowed values.
104
+
105
+ Returns:
106
+ tuple: (hit_detected, transmittance_value)
107
+ hit_detected (bool): Whether ray hit a target voxel
108
+ transmittance_value (float): Cumulative transmittance through trees
45
109
  """
46
110
  nx, ny, nz = voxel_data.shape
47
111
  x0, y0, z0 = origin
@@ -151,9 +215,28 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
151
215
  def compute_vi_generic(observer_location, voxel_data, ray_directions, hit_values, meshsize, tree_k, tree_lad, inclusion_mode=True):
152
216
  """Compute view index accounting for tree transmittance.
153
217
 
154
- For tree voxels (-2):
155
- - If -2 in hit_values: counts obstruction (1 - transmittance) as hit contribution
156
- - If -2 not in hit_values: applies transmittance normally
218
+ Casts rays in specified directions and computes visibility index based on hits and transmittance.
219
+ The view index is the ratio of visible rays to total rays cast, where:
220
+ - For inclusion mode: Counts hits with target values
221
+ - For exclusion mode: Counts rays that don't hit obstacles
222
+ Tree transmittance is handled specially:
223
+ - In inclusion mode with trees as targets: Uses (1 - transmittance) as contribution
224
+ - In exclusion mode: Uses transmittance value directly
225
+
226
+ Args:
227
+ observer_location (ndarray): Observer position (x,y,z) in voxel coordinates
228
+ voxel_data (ndarray): 3D array of voxel values
229
+ ray_directions (ndarray): Array of direction vectors for rays
230
+ hit_values (tuple): Values to check for hits
231
+ meshsize (float): Size of each voxel in meters
232
+ tree_k (float): Tree extinction coefficient
233
+ tree_lad (float): Leaf area density in m^-1
234
+ inclusion_mode (bool): If True, hit_values are hits. If False, hit_values are allowed values.
235
+
236
+ Returns:
237
+ float: View index value between 0 and 1
238
+ 0.0 = no visibility in any direction
239
+ 1.0 = full visibility in all directions
157
240
  """
158
241
  total_rays = ray_directions.shape[0]
159
242
  visibility_sum = 0.0
@@ -180,7 +263,31 @@ def compute_vi_generic(observer_location, voxel_data, ray_directions, hit_values
180
263
  @njit(parallel=True)
181
264
  def compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel, hit_values,
182
265
  meshsize, tree_k, tree_lad, inclusion_mode=True):
183
- """Compute view index map incorporating tree transmittance."""
266
+ """Compute view index map incorporating tree transmittance.
267
+
268
+ Places observers at valid locations and computes view index for each position.
269
+ Valid observer locations are:
270
+ - Empty voxels (0) or tree voxels (-2)
271
+ - Above non-empty, non-tree voxels
272
+ - Not above water (7,8,9) or negative values
273
+
274
+ The function processes each x,y position in parallel for efficiency.
275
+
276
+ Args:
277
+ voxel_data (ndarray): 3D array of voxel values
278
+ ray_directions (ndarray): Array of direction vectors for rays
279
+ view_height_voxel (int): Observer height in voxel units
280
+ hit_values (tuple): Values to check for hits
281
+ meshsize (float): Size of each voxel in meters
282
+ tree_k (float): Tree extinction coefficient
283
+ tree_lad (float): Leaf area density in m^-1
284
+ inclusion_mode (bool): If True, hit_values are hits. If False, hit_values are allowed values.
285
+
286
+ Returns:
287
+ ndarray: 2D array of view index values
288
+ NaN = invalid observer location
289
+ 0.0-1.0 = view index value
290
+ """
184
291
  nx, ny, nz = voxel_data.shape
185
292
  vi_map = np.full((nx, ny), np.nan)
186
293
 
@@ -188,12 +295,15 @@ def compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel, hit_va
188
295
  for y in range(ny):
189
296
  found_observer = False
190
297
  for z in range(1, nz):
298
+ # Check for valid observer location
191
299
  if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
192
- if voxel_data[x, y, z - 1] in (-3, -2):
300
+ # Skip invalid ground types
301
+ if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
193
302
  vi_map[x, y] = np.nan
194
303
  found_observer = True
195
304
  break
196
305
  else:
306
+ # Place observer and compute view index
197
307
  observer_location = np.array([x, y, z + view_height_voxel], dtype=np.float64)
198
308
  vi_value = compute_vi_generic(observer_location, voxel_data, ray_directions,
199
309
  hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
@@ -208,6 +318,14 @@ def compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel, hit_va
208
318
  def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_mode=True, **kwargs):
209
319
  """Calculate and visualize a generic view index for a voxel city model.
210
320
 
321
+ This is a high-level function that provides a flexible interface for computing
322
+ various view indices. It handles:
323
+ - Mode presets for common indices (green, sky)
324
+ - Ray direction generation
325
+ - Tree transmittance parameters
326
+ - Visualization
327
+ - Optional OBJ export
328
+
211
329
  Args:
212
330
  voxel_data (ndarray): 3D array of voxel values.
213
331
  meshsize (float): Size of each voxel in meters.
@@ -321,15 +439,21 @@ def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_m
321
439
 
322
440
  return vi_map
323
441
 
324
- def mark_building_by_id(voxcity_grid, building_id_grid_ori, ids, mark):
442
+ def mark_building_by_id(voxcity_grid_ori, building_id_grid_ori, ids, mark):
325
443
  """Mark specific buildings in the voxel grid with a given value.
326
444
 
445
+ Used to identify landmark buildings for visibility analysis.
446
+ Flips building ID grid vertically to match voxel grid orientation.
447
+
327
448
  Args:
328
449
  voxcity_grid (ndarray): 3D array of voxel values
329
450
  building_id_grid_ori (ndarray): 2D array of building IDs
330
451
  ids (list): List of building IDs to mark
331
452
  mark (int): Value to mark the buildings with
332
453
  """
454
+
455
+ voxcity_grid = voxcity_grid_ori.copy()
456
+
333
457
  # Flip building ID grid vertically to match voxel grid orientation
334
458
  building_id_grid = np.flipud(building_id_grid_ori.copy())
335
459
 
@@ -342,17 +466,20 @@ def mark_building_by_id(voxcity_grid, building_id_grid_ori, ids, mark):
342
466
  # Replace building voxels (-3) with mark value at this x,y position
343
467
  z_mask = voxcity_grid[x, y, :] == -3
344
468
  voxcity_grid[x, y, z_mask] = mark
469
+
470
+ return voxcity_grid
345
471
 
346
472
  @njit
347
473
  def trace_ray_to_target(voxel_data, origin, target, opaque_values):
348
474
  """Trace a ray from origin to target through voxel data.
349
475
 
350
476
  Uses DDA algorithm to efficiently traverse voxels along ray path.
477
+ Checks for any opaque voxels blocking the line of sight.
351
478
 
352
479
  Args:
353
480
  voxel_data (ndarray): 3D array of voxel values
354
- origin (tuple): Starting point (x,y,z) of ray
355
- target (tuple): End point (x,y,z) of ray
481
+ origin (tuple): Starting point (x,y,z) in voxel coordinates
482
+ target (tuple): End point (x,y,z) in voxel coordinates
356
483
  opaque_values (ndarray): Array of voxel values that block the ray
357
484
 
358
485
  Returns:
@@ -443,8 +570,11 @@ def trace_ray_to_target(voxel_data, origin, target, opaque_values):
443
570
  def compute_visibility_to_all_landmarks(observer_location, landmark_positions, voxel_data, opaque_values):
444
571
  """Check if any landmark is visible from the observer location.
445
572
 
573
+ Traces rays to each landmark position until finding one that's visible.
574
+ Uses optimized ray tracing with early exit on first visible landmark.
575
+
446
576
  Args:
447
- observer_location (ndarray): Observer position (x,y,z)
577
+ observer_location (ndarray): Observer position (x,y,z) in voxel coordinates
448
578
  landmark_positions (ndarray): Array of landmark positions
449
579
  voxel_data (ndarray): 3D array of voxel values
450
580
  opaque_values (ndarray): Array of voxel values that block visibility
@@ -467,6 +597,12 @@ def compute_visibility_map(voxel_data, landmark_positions, opaque_values, view_h
467
597
  Places observers at valid locations (empty voxels above ground, excluding building
468
598
  roofs and vegetation) and checks visibility to any landmark.
469
599
 
600
+ The function processes each x,y position in parallel for efficiency.
601
+ Valid observer locations are:
602
+ - Empty voxels (0) or tree voxels (-2)
603
+ - Above non-empty, non-tree voxels
604
+ - Not above water (7,8,9) or negative values
605
+
470
606
  Args:
471
607
  voxel_data (ndarray): 3D array of voxel values
472
608
  landmark_positions (ndarray): Array of landmark positions
@@ -474,7 +610,10 @@ def compute_visibility_map(voxel_data, landmark_positions, opaque_values, view_h
474
610
  view_height_voxel (int): Height offset for observer in voxels
475
611
 
476
612
  Returns:
477
- ndarray: 2D array of visibility values (0 or 1)
613
+ ndarray: 2D array of visibility values
614
+ NaN = invalid observer location
615
+ 0 = no landmarks visible
616
+ 1 = at least one landmark visible
478
617
  """
479
618
  nx, ny, nz = voxel_data.shape
480
619
  visibility_map = np.full((nx, ny), np.nan)
@@ -509,6 +648,12 @@ def compute_landmark_visibility(voxel_data, target_value=-30, view_height_voxel=
509
648
  Places observers at valid locations and checks visibility to any landmark voxel.
510
649
  Generates a binary visibility map and visualization.
511
650
 
651
+ The function:
652
+ 1. Identifies all landmark voxels (target_value)
653
+ 2. Determines which voxel values block visibility
654
+ 3. Computes visibility from each valid observer location
655
+ 4. Generates visualization with legend
656
+
512
657
  Args:
513
658
  voxel_data (ndarray): 3D array of voxel values
514
659
  target_value (int, optional): Value used to identify landmark voxels. Defaults to -30.
@@ -517,6 +662,9 @@ def compute_landmark_visibility(voxel_data, target_value=-30, view_height_voxel=
517
662
 
518
663
  Returns:
519
664
  ndarray: 2D array of visibility values (0 or 1) with y-axis flipped
665
+ NaN = invalid observer location
666
+ 0 = no landmarks visible
667
+ 1 = at least one landmark visible
520
668
 
521
669
  Raises:
522
670
  ValueError: If no landmark voxels are found with the specified target_value
@@ -553,7 +701,7 @@ def compute_landmark_visibility(voxel_data, target_value=-30, view_height_voxel=
553
701
 
554
702
  return np.flipud(visibility_map)
555
703
 
556
- def get_landmark_visibility_map(voxcity_grid, building_id_grid, building_geojson, meshsize, **kwargs):
704
+ def get_landmark_visibility_map(voxcity_grid_ori, building_id_grid, building_geojson, meshsize, **kwargs):
557
705
  """Generate a visibility map for landmark buildings in a voxel city.
558
706
 
559
707
  Places observers at valid locations and checks visibility to any part of the
@@ -590,25 +738,29 @@ def get_landmark_visibility_map(voxcity_grid, building_id_grid, building_geojson
590
738
  # Get landmark building IDs either directly or by finding buildings in rectangle
591
739
  features = building_geojson
592
740
  landmark_ids = kwargs.get('landmark_building_ids', None)
741
+ landmark_polygon = kwargs.get('landmark_polygon', None)
593
742
  if landmark_ids is None:
594
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
595
- if rectangle_vertices is None:
596
- print("Cannot set landmark buildings. You need to input either of rectangle_vertices or landmark_ids.")
597
- return None
743
+ if landmark_polygon is not None:
744
+ landmark_ids = get_buildings_in_drawn_polygon(building_geojson, landmark_polygon, operation='within')
745
+ else:
746
+ rectangle_vertices = kwargs.get("rectangle_vertices", None)
747
+ if rectangle_vertices is None:
748
+ print("Cannot set landmark buildings. You need to input either of rectangle_vertices or landmark_ids.")
749
+ return None
750
+
751
+ # Calculate center point of rectangle
752
+ lats = [coord[0] for coord in rectangle_vertices]
753
+ lons = [coord[1] for coord in rectangle_vertices]
754
+ center_lat = (min(lats) + max(lats)) / 2
755
+ center_lon = (min(lons) + max(lons)) / 2
756
+ target_point = (center_lat, center_lon)
598
757
 
599
- # Calculate center point of rectangle
600
- lats = [coord[0] for coord in rectangle_vertices]
601
- lons = [coord[1] for coord in rectangle_vertices]
602
- center_lat = (min(lats) + max(lats)) / 2
603
- center_lon = (min(lons) + max(lons)) / 2
604
- target_point = (center_lat, center_lon)
605
-
606
- # Find buildings at center point
607
- landmark_ids = find_building_containing_point(features, target_point)
758
+ # Find buildings at center point
759
+ landmark_ids = find_building_containing_point(features, target_point)
608
760
 
609
761
  # Mark landmark buildings in voxel grid with special value
610
762
  target_value = -30
611
- mark_building_by_id(voxcity_grid, building_id_grid, landmark_ids, target_value)
763
+ voxcity_grid = mark_building_by_id(voxcity_grid_ori, building_id_grid, landmark_ids, target_value)
612
764
 
613
765
  # Compute visibility map
614
766
  landmark_vis_map = compute_landmark_visibility(voxcity_grid, target_value=target_value, view_height_voxel=view_height_voxel, colormap=colormap)
@@ -641,7 +793,7 @@ def get_landmark_visibility_map(voxcity_grid, building_id_grid, building_geojson
641
793
  output_file_name_vox = 'voxcity_' + output_file_name
642
794
  export_obj(voxcity_grid, output_dir, output_file_name_vox, meshsize)
643
795
 
644
- return landmark_vis_map
796
+ return landmark_vis_map, voxcity_grid
645
797
 
646
798
  def get_sky_view_factor_map(voxel_data, meshsize, show_plot=False, **kwargs):
647
799
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: voxcity
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data
5
5
  Author-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
6
6
  Maintainer-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
@@ -305,6 +305,62 @@ export_magicavoxel_vox(voxcity_grid, output_path, base_filename=base_filename)
305
305
 
306
306
  ### 6. Additional Use Cases
307
307
 
308
+ #### Compute Solar Irradiance:
309
+
310
+ ```python
311
+ from voxcity.sim.solar import get_global_solar_irradiance_using_epw
312
+
313
+ solar_kwargs = {
314
+ "download_nearest_epw": True, # Whether to automatically download nearest EPW weather file based on location from Climate.OneBuilding.Org
315
+ "rectangle_vertices": rectangle_vertices, # Coordinates defining the area of interest for calculation
316
+ # "epw_file_path": "./output/new.york-downtown.manhattan.heli_ny_usa_1.epw", # Path to EnergyPlus Weather (EPW) file containing climate data. Set if you already have an EPW file.
317
+ "calc_time": "01-01 12:00:00", # Time for instantaneous calculation in format "MM-DD HH:MM:SS"
318
+ "view_point_height": 1.5, # Height of view point in meters for calculating solar access. Default: 1.5 m
319
+ "tree_k": 0.6, # Static extinction coefficient - controls how much sunlight is blocked by trees (higher = more blocking)
320
+ "tree_lad": 1.0, # Leaf area density of trees - density of leaves/branches that affect shading (higher = denser foliage)
321
+ "dem_grid": dem_grid, # Digital elevation model grid for terrain heights
322
+ "colormap": 'magma', # Matplotlib colormap for visualization. Default: 'viridis'
323
+ "obj_export": True, # Whether to export results as 3D OBJ file
324
+ "output_directory": 'output/test', # Directory for saving output files
325
+ "output_file_name": 'instantaneous_solar_irradiance', # Base filename for outputs (without extension)
326
+ "alpha": 1.0, # Transparency of visualization (0.0-1.0)
327
+ "vmin": 0, # Minimum value for colormap scaling in visualization
328
+ # "vmax": 900, # Maximum value for colormap scaling in visualization
329
+ }
330
+
331
+ # Compute global solar irradiance map (direct + diffuse radiation)
332
+ global_map = get_global_solar_irradiance_using_epw(
333
+ voxcity_grid, # 3D voxel grid representing the urban environment
334
+ meshsize, # Size of each voxel in meters
335
+ calc_type='instantaneous', # Calculate instantaneous irradiance at specified time
336
+ direct_normal_irradiance_scaling=1.0, # Scaling factor for direct solar radiation (1.0 = no scaling)
337
+ diffuse_irradiance_scaling=1.0, # Scaling factor for diffuse solar radiation (1.0 = no scaling)
338
+ **solar_kwargs # Pass all the parameters defined above
339
+ )
340
+
341
+ # Adjust parameters for cumulative calculation
342
+ solar_kwargs["start_time"] = "01-01 01:00:00" # Start time for cumulative calculation
343
+ solar_kwargs["end_time"] = "01-31 23:00:00" # End time for cumulative calculation
344
+ solar_kwargs["output_file_name"] = 'cummulative_solar_irradiance', # Base filename for outputs (without extension)
345
+
346
+ # Calculate cumulative solar irradiance over the specified time period
347
+ global_map = get_global_solar_irradiance_using_epw(
348
+ voxcity_grid, # 3D voxel grid representing the urban environment
349
+ meshsize, # Size of each voxel in meters
350
+ calc_type='cumulative', # Calculate cumulative irradiance over time period instead of instantaneous
351
+ direct_normal_irradiance_scaling=1.0, # Scaling factor for direct solar radiation (1.0 = no scaling)
352
+ diffuse_irradiance_scaling=1.0, # Scaling factor for diffuse solar radiation (1.0 = no scaling)
353
+ **solar_kwargs # Pass all the parameters defined above
354
+ )
355
+ ```
356
+
357
+ <p align="center">
358
+ <img src="https://raw.githubusercontent.com/kunifujiwara/VoxCity/main/images/solar.png" alt="Solar Irradiance Maps Rendered in Rhino" width="800">
359
+ </p>
360
+ <p align="center">
361
+ <em>Example Results Saved as OBJ and Rendered in Rhino</em>
362
+ </p>
363
+
308
364
  #### Compute Green View Index (GVI) and Sky View Index (SVI):
309
365
 
310
366
  ```python
@@ -11,24 +11,24 @@ voxcity/download/overture.py,sha256=R6XtC2iP6Xp6e2Otop4FXs97gCW_bAuFQ_RCOPiHbjo,
11
11
  voxcity/download/utils.py,sha256=z6MdPxM96FWQVqvZW2Eg5pMewVHVysUP7F6ueeCwMfI,1375
12
12
  voxcity/file/__init_.py,sha256=cVyNyE6axEpSd3CT5hGuMOAlOyU1p8lVP4jkF1-0Ad8,94
13
13
  voxcity/file/envimet.py,sha256=s3qw3kI8sO5996xdnB0MgPCCL0PvICoY1NfrtCz51Sw,24182
14
- voxcity/file/geojson.py,sha256=Wm_ABjG7lRLOWLPxt0vjP0jycomB898wNte3FEtYT_M,22301
14
+ voxcity/file/geojson.py,sha256=tlkE_strKYTB2xCdZmtAUQjqnTqHYKhpylyi4E8yi0A,25205
15
15
  voxcity/file/magicavoxel.py,sha256=Fsv7yGRXeKmp82xcG3rOb0t_HtoqltNq2tHl08xVlqY,7500
16
16
  voxcity/file/obj.py,sha256=oW-kPoZj53nfmO9tXP3Wvizq6Kkjh-QQR8UBexRuMiI,21609
17
17
  voxcity/geo/__init_.py,sha256=rsj0OMzrTNACccdvEfmf632mb03BRUtKLuecppsxX40,62
18
- voxcity/geo/draw.py,sha256=yRaJHFAztLuFRO6gJtTGqLQPQkLvGrvw3E0fucnbKPQ,9090
18
+ voxcity/geo/draw.py,sha256=tCWg2kPTbZP3wXyGGywB2Hj4viifaG554VDSjMfFWJg,13728
19
19
  voxcity/geo/grid.py,sha256=l9iqi2OCmtJixCc3Y3RthF403pdrx6sB0565wZ1uHgM,40042
20
20
  voxcity/geo/utils.py,sha256=sR9InBHxV76XjlGPLD7blg_6EjbM0MG5DOyJffhBjWk,19372
21
21
  voxcity/sim/__init_.py,sha256=APdkcdaovj0v_RPOaA4SBvFUKT2RM7Hxuuz3Sux4gCo,65
22
- voxcity/sim/solar.py,sha256=cTONZh1CXjcNXXOCD8Pn9FbVdMg3JUbtaxMUwifm0dk,20392
22
+ voxcity/sim/solar.py,sha256=VJCWWHxrKSAg-YgajzY1B9bbnBzKybMm7Tw7dBwvoGI,31306
23
23
  voxcity/sim/utils.py,sha256=sEYBB2-hLJxTiXQps1_-Fi7t1HN3-1OPOvBCWtgIisA,130
24
- voxcity/sim/view.py,sha256=3NATlsxlIfkYEo6nb_VIghgElSLCyeZtThLauLpPNXc,29415
24
+ voxcity/sim/view.py,sha256=xab2B7mKDTufJnE7UN0aPAvkhoGQuduIXZOkJTuS5fU,36682
25
25
  voxcity/utils/__init_.py,sha256=xjEadXQ9wXTw0lsx0JTbyTqASWw0GJLfT6eRr0CyQzw,71
26
26
  voxcity/utils/lc.py,sha256=RwPd-VY3POV3gTrBhM7TubgGb9MCd3nVah_G8iUEF7k,11562
27
27
  voxcity/utils/visualization.py,sha256=GVERj0noHAvJtDT0fV3K6w7pTfuAUfwKez-UMuEakEg,42214
28
28
  voxcity/utils/weather.py,sha256=Qwnr0paGdRQstwD0A9q2QfJIV-aQUyxH-6viRwXOuwM,21482
29
- voxcity-0.3.1.dist-info/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
30
- voxcity-0.3.1.dist-info/LICENSE,sha256=-hGliOFiwUrUSoZiB5WF90xXGqinKyqiDI2t6hrnam8,1087
31
- voxcity-0.3.1.dist-info/METADATA,sha256=DkgUBxIg7p7L-ftv9zR0Vxzyx4cDbQzNrZuIr2miNEs,19876
32
- voxcity-0.3.1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
33
- voxcity-0.3.1.dist-info/top_level.txt,sha256=00b2U-LKfDllt6RL1R33MXie5MvxzUFye0NGD96t_8I,8
34
- voxcity-0.3.1.dist-info/RECORD,,
29
+ voxcity-0.3.3.dist-info/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
30
+ voxcity-0.3.3.dist-info/LICENSE,sha256=-hGliOFiwUrUSoZiB5WF90xXGqinKyqiDI2t6hrnam8,1087
31
+ voxcity-0.3.3.dist-info/METADATA,sha256=F5jlTMuhl1a3-SfpO60KbdhCZ-Ke85BDA7w1OpGD8jM,23607
32
+ voxcity-0.3.3.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
33
+ voxcity-0.3.3.dist-info/top_level.txt,sha256=00b2U-LKfDllt6RL1R33MXie5MvxzUFye0NGD96t_8I,8
34
+ voxcity-0.3.3.dist-info/RECORD,,