voxcity 0.6.26__py3-none-any.whl → 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. voxcity/__init__.py +10 -4
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +9 -1
  10. voxcity/exporter/cityles.py +129 -34
  11. voxcity/exporter/envimet.py +51 -26
  12. voxcity/exporter/magicavoxel.py +42 -5
  13. voxcity/exporter/netcdf.py +27 -0
  14. voxcity/exporter/obj.py +103 -28
  15. voxcity/generator/__init__.py +47 -0
  16. voxcity/generator/api.py +721 -0
  17. voxcity/generator/grids.py +381 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/update.py +429 -0
  21. voxcity/generator/voxelizer.py +392 -0
  22. voxcity/geoprocessor/__init__.py +75 -6
  23. voxcity/geoprocessor/conversion.py +153 -0
  24. voxcity/geoprocessor/draw.py +1488 -1169
  25. voxcity/geoprocessor/heights.py +199 -0
  26. voxcity/geoprocessor/io.py +101 -0
  27. voxcity/geoprocessor/merge_utils.py +91 -0
  28. voxcity/geoprocessor/mesh.py +26 -10
  29. voxcity/geoprocessor/network.py +35 -6
  30. voxcity/geoprocessor/overlap.py +84 -0
  31. voxcity/geoprocessor/raster/__init__.py +82 -0
  32. voxcity/geoprocessor/raster/buildings.py +435 -0
  33. voxcity/geoprocessor/raster/canopy.py +258 -0
  34. voxcity/geoprocessor/raster/core.py +150 -0
  35. voxcity/geoprocessor/raster/export.py +93 -0
  36. voxcity/geoprocessor/raster/landcover.py +159 -0
  37. voxcity/geoprocessor/raster/raster.py +110 -0
  38. voxcity/geoprocessor/selection.py +85 -0
  39. voxcity/geoprocessor/utils.py +824 -820
  40. voxcity/models.py +113 -0
  41. voxcity/simulator/common/__init__.py +22 -0
  42. voxcity/simulator/common/geometry.py +98 -0
  43. voxcity/simulator/common/raytracing.py +450 -0
  44. voxcity/simulator/solar/__init__.py +66 -0
  45. voxcity/simulator/solar/integration.py +336 -0
  46. voxcity/simulator/solar/kernels.py +62 -0
  47. voxcity/simulator/solar/radiation.py +648 -0
  48. voxcity/simulator/solar/sky.py +668 -0
  49. voxcity/simulator/solar/temporal.py +792 -0
  50. voxcity/simulator/view.py +36 -2286
  51. voxcity/simulator/visibility/__init__.py +29 -0
  52. voxcity/simulator/visibility/landmark.py +392 -0
  53. voxcity/simulator/visibility/view.py +508 -0
  54. voxcity/utils/__init__.py +11 -0
  55. voxcity/utils/classes.py +194 -0
  56. voxcity/utils/lc.py +80 -39
  57. voxcity/utils/logging.py +61 -0
  58. voxcity/utils/orientation.py +51 -0
  59. voxcity/utils/shape.py +230 -0
  60. voxcity/utils/weather/__init__.py +26 -0
  61. voxcity/utils/weather/epw.py +146 -0
  62. voxcity/utils/weather/files.py +36 -0
  63. voxcity/utils/weather/onebuilding.py +486 -0
  64. voxcity/visualizer/__init__.py +24 -0
  65. voxcity/visualizer/builder.py +43 -0
  66. voxcity/visualizer/grids.py +141 -0
  67. voxcity/visualizer/maps.py +187 -0
  68. voxcity/visualizer/palette.py +228 -0
  69. voxcity/visualizer/renderer.py +1145 -0
  70. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
  71. voxcity-1.0.2.dist-info/RECORD +81 -0
  72. voxcity/generator.py +0 -1302
  73. voxcity/geoprocessor/grid.py +0 -1739
  74. voxcity/geoprocessor/polygon.py +0 -1344
  75. voxcity/simulator/solar.py +0 -2339
  76. voxcity/utils/visualization.py +0 -2849
  77. voxcity/utils/weather.py +0 -1038
  78. voxcity-0.6.26.dist-info/RECORD +0 -38
  79. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  80. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
voxcity/utils/shape.py ADDED
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Tuple, Optional, Dict, Any, Callable
4
+
5
+ import numpy as np
6
+
7
+ from ..models import VoxCity, VoxelGrid, BuildingGrid, LandCoverGrid, DemGrid, CanopyGrid
8
+
9
+
10
+ def _compute_center_crop_indices(size: int, target: int) -> Tuple[int, int]:
11
+ if size <= target:
12
+ return 0, size
13
+ start = max(0, (size - target) // 2)
14
+ end = start + target
15
+ return start, end
16
+
17
+
18
+ def _pad_split(total_pad: int) -> Tuple[int, int]:
19
+ # Split padding for centering; put extra on the bottom/right side
20
+ a = total_pad // 2
21
+ b = total_pad - a
22
+ return a, b
23
+
24
+
25
+ def _pad_crop_2d(
26
+ arr: np.ndarray,
27
+ target_xy: Tuple[int, int],
28
+ pad_value: Any,
29
+ align_xy: str = "center",
30
+ allow_crop_xy: bool = True,
31
+ ) -> np.ndarray:
32
+ x, y = arr.shape[:2]
33
+ tx, ty = int(target_xy[0]), int(target_xy[1])
34
+
35
+ # Crop (center) if needed and allowed
36
+ if allow_crop_xy and (x > tx or y > ty):
37
+ if align_xy == "center":
38
+ xs, xe = _compute_center_crop_indices(x, tx) if x > tx else (0, x)
39
+ ys, ye = _compute_center_crop_indices(y, ty) if y > ty else (0, y)
40
+ else:
41
+ # top-left alignment: crop from bottom/right only
42
+ xs, xe = (0, tx) if x > tx else (0, x)
43
+ ys, ye = (0, ty) if y > ty else (0, y)
44
+ arr = arr[xs:xe, ys:ye]
45
+ x, y = arr.shape[:2]
46
+
47
+ # Pad to target
48
+ px = max(0, tx - x)
49
+ py = max(0, ty - y)
50
+
51
+ if px == 0 and py == 0:
52
+ return arr
53
+
54
+ if align_xy == "center":
55
+ px0, px1 = _pad_split(px)
56
+ py0, py1 = _pad_split(py)
57
+ else:
58
+ # top-left: pad only on bottom/right
59
+ px0, px1 = 0, px
60
+ py0, py1 = 0, py
61
+
62
+ if arr.ndim == 2:
63
+ pad_width = ((px0, px1), (py0, py1))
64
+ else:
65
+ # Preserve trailing dims (e.g., channels)
66
+ pad_width = ((px0, px1), (py0, py1)) + tuple((0, 0) for _ in range(arr.ndim - 2))
67
+
68
+ return np.pad(arr, pad_width, mode="constant", constant_values=pad_value)
69
+
70
+
71
+ def _pad_crop_3d_zbottom(
72
+ arr: np.ndarray,
73
+ target_shape: Tuple[int, int, int],
74
+ pad_value: Any,
75
+ align_xy: str = "center",
76
+ allow_crop_xy: bool = True,
77
+ allow_crop_z: bool = False,
78
+ ) -> np.ndarray:
79
+ nx, ny, nz = arr.shape
80
+ tx, ty, tz = int(target_shape[0]), int(target_shape[1]), int(target_shape[2])
81
+
82
+ # XY crop/pad
83
+ arr_xy = _pad_crop_2d(arr, (tx, ty), pad_value, align_xy=align_xy, allow_crop_xy=allow_crop_xy)
84
+ nx, ny, nz = arr_xy.shape
85
+
86
+ # Z handling: keep ground at z=0; pad only at the top by default
87
+ if nz > tz:
88
+ if allow_crop_z:
89
+ arr_xy = arr_xy[:, :, :tz]
90
+ else:
91
+ tz = nz # expand target to avoid cropping
92
+ elif nz < tz:
93
+ pad_top = tz - nz # add empty air above
94
+ arr_xy = np.pad(arr_xy, ((0, 0), (0, 0), (0, pad_top)), mode="constant", constant_values=pad_value)
95
+
96
+ return arr_xy
97
+
98
+
99
+ def normalize_voxcity_shape(
100
+ city: VoxCity,
101
+ target_shape: Tuple[int, int, int],
102
+ *,
103
+ align_xy: str = "center",
104
+ pad_values: Optional[Dict[str, Any]] = None,
105
+ allow_crop_xy: bool = True,
106
+ allow_crop_z: bool = False,
107
+ ) -> VoxCity:
108
+ """
109
+ Return a new VoxCity with arrays padded/cropped to target (x, y, z).
110
+
111
+ - XY alignment can be 'center' (default) or 'top-left'.
112
+ - Z padding is added at the TOP to preserve ground level at z=0.
113
+ - By default, Z is never cropped (allow_crop_z=False). If target z is smaller than current,
114
+ target z is expanded to current to avoid losing data.
115
+ """
116
+ if pad_values is None:
117
+ pad_values = {}
118
+
119
+ # Resolve pad values for each layer
120
+ pv_vox = pad_values.get("voxels", 0)
121
+ pv_lc = pad_values.get("land_cover", 0)
122
+ pv_dem = pad_values.get("dem", 0.0)
123
+ pv_bh = pad_values.get("building_heights", 0.0)
124
+ pv_bid = pad_values.get("building_ids", 0)
125
+ pv_canopy = pad_values.get("canopy", 0.0)
126
+ pv_bmin = pad_values.get("building_min_heights_factory", None) # callable creating empty cell, default []
127
+ if pv_bmin is None:
128
+ def _empty_list() -> list:
129
+ return []
130
+ pv_bmin = _empty_list
131
+ elif not callable(pv_bmin):
132
+ const_val = pv_bmin
133
+ pv_bmin = (lambda v=const_val: v)
134
+
135
+ # Source arrays
136
+ vox = city.voxels.classes
137
+ bh = city.buildings.heights
138
+ bmin = city.buildings.min_heights
139
+ bid = city.buildings.ids
140
+ lc = city.land_cover.classes
141
+ dem = city.dem.elevation
142
+ can_top = city.tree_canopy.top
143
+ can_bot = city.tree_canopy.bottom if city.tree_canopy.bottom is not None else None
144
+
145
+ # Normalize shapes
146
+ vox_n = _pad_crop_3d_zbottom(
147
+ vox.astype(vox.dtype, copy=False),
148
+ target_shape,
149
+ pad_value=np.array(pv_vox, dtype=vox.dtype),
150
+ align_xy=align_xy,
151
+ allow_crop_xy=allow_crop_xy,
152
+ allow_crop_z=allow_crop_z,
153
+ )
154
+ tx, ty, tz = vox_n.shape
155
+
156
+ def _pad2d(a: np.ndarray, pad_val: Any) -> np.ndarray:
157
+ return _pad_crop_2d(
158
+ a.astype(a.dtype, copy=False),
159
+ (tx, ty),
160
+ pad_value=np.array(pad_val, dtype=a.dtype) if not isinstance(pad_val, (list, tuple, dict)) else pad_val,
161
+ align_xy=align_xy,
162
+ allow_crop_xy=allow_crop_xy,
163
+ )
164
+
165
+ bh_n = _pad2d(bh, pv_bh)
166
+ bid_n = _pad2d(bid, pv_bid)
167
+ lc_n = _pad2d(lc, pv_lc)
168
+ dem_n = _pad2d(dem, pv_dem)
169
+ can_top_n = _pad2d(can_top, pv_canopy)
170
+ can_bot_n = _pad2d(can_bot, pv_canopy) if can_bot is not None else None # type: ignore
171
+
172
+ # Object-dtype 2D padding/cropping for min_heights
173
+ # Center-crop if needed, then pad with empty lists
174
+ bx, by = bmin.shape
175
+ if bx > tx or by > ty:
176
+ if align_xy == "center":
177
+ xs, xe = _compute_center_crop_indices(bx, tx) if bx > tx else (0, bx)
178
+ ys, ye = _compute_center_crop_indices(by, ty) if by > ty else (0, by)
179
+ else:
180
+ xs, xe = (0, tx) if bx > tx else (0, bx)
181
+ ys, ye = (0, ty) if by > ty else (0, by)
182
+ bmin_c = bmin[xs:xe, ys:ye]
183
+ else:
184
+ bmin_c = bmin
185
+ bx, by = bmin_c.shape
186
+ px = max(0, tx - bx)
187
+ py = max(0, ty - by)
188
+ if px or py:
189
+ out = np.empty((tx, ty), dtype=object)
190
+ # Fill with empty factory values
191
+ for i in range(tx):
192
+ for j in range(ty):
193
+ out[i, j] = pv_bmin()
194
+ if align_xy == "center":
195
+ px0, px1 = _pad_split(px)
196
+ py0, py1 = _pad_split(py)
197
+ else:
198
+ px0, py0 = 0, 0
199
+ px1, py1 = px, py
200
+ out[px0:px0 + bx, py0:py0 + by] = bmin_c
201
+ bmin_n = out
202
+ else:
203
+ bmin_n = bmin_c
204
+
205
+ # Rebuild VoxCity with normalized arrays and same metadata (meshsize/bounds)
206
+ meta = city.voxels.meta
207
+ voxels_new = VoxelGrid(classes=vox_n, meta=meta)
208
+ buildings_new = BuildingGrid(heights=bh_n, min_heights=bmin_n, ids=bid_n, meta=meta)
209
+ land_new = LandCoverGrid(classes=lc_n, meta=meta)
210
+ dem_new = DemGrid(elevation=dem_n, meta=meta)
211
+ canopy_new = CanopyGrid(top=can_top_n, bottom=can_bot_n, meta=meta)
212
+
213
+ city_new = VoxCity(
214
+ voxels=voxels_new,
215
+ buildings=buildings_new,
216
+ land_cover=land_new,
217
+ dem=dem_new,
218
+ tree_canopy=canopy_new,
219
+ extras=dict(city.extras) if city.extras is not None else {},
220
+ )
221
+ # Keep extras canopy mirrors in sync if present
222
+ try:
223
+ city_new.extras["canopy_top"] = can_top_n
224
+ if can_bot_n is not None:
225
+ city_new.extras["canopy_bottom"] = can_bot_n
226
+ except Exception:
227
+ pass
228
+ return city_new
229
+
230
+
@@ -0,0 +1,26 @@
1
+ """
2
+ Weather utilities subpackage.
3
+
4
+ Public API:
5
+ - safe_rename, safe_extract
6
+ - process_epw, read_epw_for_solar_simulation
7
+ - get_nearest_epw_from_climate_onebuilding
8
+
9
+ This package was introduced to split a previously monolithic module into
10
+ cohesive submodules. Backwards-compatible imports are preserved: importing
11
+ from `voxcity.utils.weather` continues to work.
12
+ """
13
+
14
+ from .files import safe_rename, safe_extract
15
+ from .epw import process_epw, read_epw_for_solar_simulation
16
+ from .onebuilding import get_nearest_epw_from_climate_onebuilding
17
+
18
+ __all__ = [
19
+ "safe_rename",
20
+ "safe_extract",
21
+ "process_epw",
22
+ "read_epw_for_solar_simulation",
23
+ "get_nearest_epw_from_climate_onebuilding",
24
+ ]
25
+
26
+
@@ -0,0 +1,146 @@
1
+ from pathlib import Path
2
+ from typing import Tuple, Union
3
+ import pandas as pd
4
+
5
+
6
+ def process_epw(epw_path: Union[str, Path]) -> Tuple[pd.DataFrame, dict]:
7
+ """
8
+ Process an EPW file into a pandas DataFrame and header metadata.
9
+ """
10
+ columns = [
11
+ 'Year', 'Month', 'Day', 'Hour', 'Minute',
12
+ 'Data Source and Uncertainty Flags',
13
+ 'Dry Bulb Temperature', 'Dew Point Temperature',
14
+ 'Relative Humidity', 'Atmospheric Station Pressure',
15
+ 'Extraterrestrial Horizontal Radiation',
16
+ 'Extraterrestrial Direct Normal Radiation',
17
+ 'Horizontal Infrared Radiation Intensity',
18
+ 'Global Horizontal Radiation',
19
+ 'Direct Normal Radiation', 'Diffuse Horizontal Radiation',
20
+ 'Global Horizontal Illuminance',
21
+ 'Direct Normal Illuminance', 'Diffuse Horizontal Illuminance',
22
+ 'Zenith Luminance', 'Wind Direction', 'Wind Speed',
23
+ 'Total Sky Cover', 'Opaque Sky Cover', 'Visibility',
24
+ 'Ceiling Height', 'Present Weather Observation',
25
+ 'Present Weather Codes', 'Precipitable Water',
26
+ 'Aerosol Optical Depth', 'Snow Depth',
27
+ 'Days Since Last Snowfall', 'Albedo',
28
+ 'Liquid Precipitation Depth', 'Liquid Precipitation Quantity'
29
+ ]
30
+
31
+ with open(epw_path, 'r') as f:
32
+ lines = f.readlines()
33
+
34
+ headers = {
35
+ 'LOCATION': lines[0].strip(),
36
+ 'DESIGN_CONDITIONS': lines[1].strip(),
37
+ 'TYPICAL_EXTREME_PERIODS': lines[2].strip(),
38
+ 'GROUND_TEMPERATURES': lines[3].strip(),
39
+ 'HOLIDAYS_DAYLIGHT_SAVINGS': lines[4].strip(),
40
+ 'COMMENTS_1': lines[5].strip(),
41
+ 'COMMENTS_2': lines[6].strip(),
42
+ 'DATA_PERIODS': lines[7].strip()
43
+ }
44
+
45
+ location = headers['LOCATION'].split(',')
46
+ if len(location) >= 10:
47
+ headers['LOCATION'] = {
48
+ 'City': location[1].strip(),
49
+ 'State': location[2].strip(),
50
+ 'Country': location[3].strip(),
51
+ 'Data Source': location[4].strip(),
52
+ 'WMO': location[5].strip(),
53
+ 'Latitude': float(location[6]),
54
+ 'Longitude': float(location[7]),
55
+ 'Time Zone': float(location[8]),
56
+ 'Elevation': float(location[9])
57
+ }
58
+
59
+ data = [line.strip().split(',') for line in lines[8:]]
60
+ df = pd.DataFrame(data, columns=columns)
61
+
62
+ numeric_columns = [
63
+ 'Year', 'Month', 'Day', 'Hour', 'Minute',
64
+ 'Dry Bulb Temperature', 'Dew Point Temperature',
65
+ 'Relative Humidity', 'Atmospheric Station Pressure',
66
+ 'Extraterrestrial Horizontal Radiation',
67
+ 'Extraterrestrial Direct Normal Radiation',
68
+ 'Horizontal Infrared Radiation Intensity',
69
+ 'Global Horizontal Radiation',
70
+ 'Direct Normal Radiation', 'Diffuse Horizontal Radiation',
71
+ 'Global Horizontal Illuminance',
72
+ 'Direct Normal Illuminance', 'Diffuse Horizontal Illuminance',
73
+ 'Zenith Luminance', 'Wind Direction', 'Wind Speed',
74
+ 'Total Sky Cover', 'Opaque Sky Cover', 'Visibility',
75
+ 'Ceiling Height', 'Precipitable Water',
76
+ 'Aerosol Optical Depth', 'Snow Depth',
77
+ 'Days Since Last Snowfall', 'Albedo',
78
+ 'Liquid Precipitation Depth', 'Liquid Precipitation Quantity'
79
+ ]
80
+ for col in numeric_columns:
81
+ df[col] = pd.to_numeric(df[col], errors='coerce')
82
+
83
+ df['datetime'] = pd.to_datetime({
84
+ 'year': df['Year'],
85
+ 'month': df['Month'],
86
+ 'day': df['Day'],
87
+ 'hour': df['Hour'] - 1,
88
+ 'minute': df['Minute']
89
+ })
90
+ df.set_index('datetime', inplace=True)
91
+ return df, headers
92
+
93
+
94
+ def read_epw_for_solar_simulation(epw_file_path):
95
+ """
96
+ Read EPW file specifically for solar simulation purposes.
97
+ Returns (df[DNI,DHI], lon, lat, tz, elevation_m).
98
+ """
99
+ epw_path_obj = Path(epw_file_path)
100
+ if not epw_path_obj.exists() or not epw_path_obj.is_file():
101
+ raise FileNotFoundError(f"EPW file not found: {epw_file_path}")
102
+
103
+ with open(epw_path_obj, 'r', encoding='utf-8') as f:
104
+ lines = f.readlines()
105
+
106
+ location_line = None
107
+ for line in lines:
108
+ if line.startswith("LOCATION"):
109
+ location_line = line.strip().split(',')
110
+ break
111
+ if location_line is None:
112
+ raise ValueError("Could not find LOCATION line in EPW file.")
113
+
114
+ lat = float(location_line[6])
115
+ lon = float(location_line[7])
116
+ tz = float(location_line[8])
117
+ elevation_m = float(location_line[9])
118
+
119
+ data_start_index = None
120
+ for i, line in enumerate(lines):
121
+ vals = line.strip().split(',')
122
+ if i >= 8 and len(vals) > 30:
123
+ data_start_index = i
124
+ break
125
+ if data_start_index is None:
126
+ raise ValueError("Could not find start of weather data lines in EPW file.")
127
+
128
+ data = []
129
+ for l in lines[data_start_index:]:
130
+ vals = l.strip().split(',')
131
+ if len(vals) < 15:
132
+ continue
133
+ year = int(vals[0])
134
+ month = int(vals[1])
135
+ day = int(vals[2])
136
+ hour = int(vals[3]) - 1
137
+ dni = float(vals[14])
138
+ dhi = float(vals[15])
139
+ timestamp = pd.Timestamp(year, month, day, hour)
140
+ data.append([timestamp, dni, dhi])
141
+
142
+ df = pd.DataFrame(data, columns=['time', 'DNI', 'DHI']).set_index('time')
143
+ df = df.sort_index()
144
+ return df, lon, lat, tz, elevation_m
145
+
146
+
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+ import os
3
+ import zipfile
4
+
5
+
6
+ def safe_rename(src: Path, dst: Path) -> Path:
7
+ """
8
+ Safely rename a file, handling existing files by adding a number suffix.
9
+ """
10
+ if not dst.exists():
11
+ src.rename(dst)
12
+ return dst
13
+ base = dst.stem
14
+ ext = dst.suffix
15
+ counter = 1
16
+ while True:
17
+ new_dst = dst.with_name(f"{base}_{counter}{ext}")
18
+ if not new_dst.exists():
19
+ src.rename(new_dst)
20
+ return new_dst
21
+ counter += 1
22
+
23
+
24
+ def safe_extract(zip_ref: zipfile.ZipFile, filename: str, extract_dir: Path) -> Path:
25
+ """
26
+ Safely extract a file from zip, handling existing files.
27
+ """
28
+ try:
29
+ zip_ref.extract(filename, extract_dir)
30
+ return extract_dir / filename
31
+ except FileExistsError:
32
+ temp_name = f"temp_{os.urandom(4).hex()}_{filename}"
33
+ zip_ref.extract(filename, extract_dir, temp_name)
34
+ return extract_dir / temp_name
35
+
36
+