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
@@ -0,0 +1,194 @@
1
+ """
2
+ VoxCity Class Definitions
3
+
4
+ This module provides standard class definitions for voxel grid semantics
5
+ and land cover classification used throughout VoxCity.
6
+
7
+ Voxel Grid Semantics:
8
+ The 3D voxel grid uses integer codes to represent different urban elements.
9
+ Negative values represent structural elements, while positive values
10
+ represent land cover classes at the ground surface layer.
11
+
12
+ Land Cover Classes:
13
+ Land cover is standardized to a 1-based indexing system (1-14) for
14
+ consistency across different data sources. This standard classification
15
+ is used in the voxel representation and exports.
16
+ """
17
+
18
+ import numpy as np
19
+ from typing import Dict, Optional
20
+
21
+
22
+ # =============================================================================
23
+ # Voxel Semantic Codes
24
+ # =============================================================================
25
+
26
+ VOXEL_CODES = {
27
+ -3: "Building",
28
+ -2: "Tree Canopy",
29
+ -1: "Ground/Subsurface",
30
+ # Positive values (>=1) represent land cover classes at ground surface
31
+ }
32
+
33
+ VOXEL_CODE_DESCRIPTIONS = """
34
+ Voxel Grid Semantic Codes:
35
+ -3 : Building volume
36
+ -2 : Tree canopy (vegetation)
37
+ -1 : Ground/Subsurface
38
+ >=1: Land cover class at ground surface (see Land Cover Classes)
39
+ """
40
+
41
+
42
+ # =============================================================================
43
+ # Standard Land Cover Classes (1-based indices)
44
+ # =============================================================================
45
+
46
+ LAND_COVER_CLASSES = {
47
+ 1: "Bareland",
48
+ 2: "Rangeland",
49
+ 3: "Shrub",
50
+ 4: "Agriculture land",
51
+ 5: "Tree",
52
+ 6: "Moss and lichen",
53
+ 7: "Wet land",
54
+ 8: "Mangrove",
55
+ 9: "Water",
56
+ 10: "Snow and ice",
57
+ 11: "Developed space",
58
+ 12: "Road",
59
+ 13: "Building",
60
+ 14: "No Data",
61
+ }
62
+
63
+ LAND_COVER_DESCRIPTIONS = """
64
+ VoxCity Standard Land Cover Classes (1-based indices, used in voxel grids):
65
+ --------------------------------------------------
66
+ 1: Bareland - Bare soil, rocks, desert
67
+ 2: Rangeland - Grassland, pasture
68
+ 3: Shrub - Shrubland, bushes
69
+ 4: Agriculture land - Cropland, farmland
70
+ 5: Tree - Forest, tree cover
71
+ 6: Moss and lichen - Moss, lichen cover
72
+ 7: Wet land - Wetland, marsh
73
+ 8: Mangrove - Mangrove forest
74
+ 9: Water - Water bodies
75
+ 10: Snow and ice - Snow, ice, glaciers
76
+ 11: Developed space - Urban areas, parking
77
+ 12: Road - Roads, paved surfaces
78
+ 13: Building - Building footprints
79
+ 14: No Data - Missing or invalid data
80
+ --------------------------------------------------
81
+ Note: Source-specific land cover classes are converted to these
82
+ standard classes during voxelization.
83
+ """
84
+
85
+
86
+ # =============================================================================
87
+ # Print Helper Functions
88
+ # =============================================================================
89
+
90
+ def print_voxel_codes() -> None:
91
+ """Print voxel semantic codes to console."""
92
+ print(VOXEL_CODE_DESCRIPTIONS)
93
+
94
+
95
+ def print_land_cover_classes() -> None:
96
+ """Print standard land cover class definitions to console."""
97
+ print(LAND_COVER_DESCRIPTIONS)
98
+
99
+
100
+ def print_class_definitions() -> None:
101
+ """Print both voxel codes and land cover class definitions."""
102
+ print("\n" + "=" * 60)
103
+ print("VoxCity Class Definitions")
104
+ print("=" * 60)
105
+ print(VOXEL_CODE_DESCRIPTIONS)
106
+ print(LAND_COVER_DESCRIPTIONS)
107
+ print("=" * 60 + "\n")
108
+
109
+
110
+ def get_land_cover_name(index: int) -> str:
111
+ """
112
+ Get the land cover class name for a given index.
113
+
114
+ Args:
115
+ index: Land cover class index (1-14)
116
+
117
+ Returns:
118
+ Class name string, or "Unknown" if index is invalid
119
+ """
120
+ return LAND_COVER_CLASSES.get(index, "Unknown")
121
+
122
+
123
+ def get_voxel_code_name(code: int) -> str:
124
+ """
125
+ Get the semantic name for a voxel code.
126
+
127
+ Args:
128
+ code: Voxel code (negative for structures, positive for land cover)
129
+
130
+ Returns:
131
+ Semantic name string
132
+ """
133
+ if code in VOXEL_CODES:
134
+ return VOXEL_CODES[code]
135
+ elif code >= 1:
136
+ return f"Land Cover: {get_land_cover_name(code)}"
137
+ elif code == 0:
138
+ return "Empty/Air"
139
+ else:
140
+ return "Unknown"
141
+
142
+
143
+ def summarize_voxel_grid(voxel_grid: np.ndarray, print_output: bool = True) -> Dict[int, int]:
144
+ """
145
+ Summarize the contents of a voxel grid.
146
+
147
+ Args:
148
+ voxel_grid: 3D numpy array of voxel codes
149
+ print_output: Whether to print the summary
150
+
151
+ Returns:
152
+ Dictionary mapping voxel codes to counts
153
+ """
154
+ unique, counts = np.unique(voxel_grid, return_counts=True)
155
+ summary = dict(zip(unique.tolist(), counts.tolist()))
156
+
157
+ if print_output:
158
+ print("\nVoxel Grid Summary:")
159
+ print("-" * 40)
160
+ for code in sorted(summary.keys()):
161
+ name = get_voxel_code_name(code)
162
+ count = summary[code]
163
+ percentage = 100.0 * count / voxel_grid.size
164
+ print(f" {code:4d}: {name:25s} - {count:,} voxels ({percentage:.2f}%)")
165
+ print("-" * 40)
166
+
167
+ return summary
168
+
169
+
170
+ def summarize_land_cover_grid(land_cover_grid: np.ndarray, print_output: bool = True) -> Dict[int, int]:
171
+ """
172
+ Summarize the contents of a land cover grid.
173
+
174
+ Args:
175
+ land_cover_grid: 2D numpy array of land cover class indices
176
+ print_output: Whether to print the summary
177
+
178
+ Returns:
179
+ Dictionary mapping class indices to counts
180
+ """
181
+ unique, counts = np.unique(land_cover_grid, return_counts=True)
182
+ summary = dict(zip(unique.tolist(), counts.tolist()))
183
+
184
+ if print_output:
185
+ print("\nLand Cover Grid Summary:")
186
+ print("-" * 40)
187
+ for idx in sorted(summary.keys()):
188
+ name = get_land_cover_name(idx) if idx >= 1 else f"Source-specific ({idx})"
189
+ count = summary[idx]
190
+ percentage = 100.0 * count / land_cover_grid.size
191
+ print(f" {idx:4d}: {name:25s} - {count:,} cells ({percentage:.2f}%)")
192
+ print("-" * 40)
193
+
194
+ return summary
voxcity/utils/lc.py CHANGED
@@ -34,21 +34,22 @@ def rgb_distance(color1, color2):
34
34
  return np.sqrt(np.sum((np.array(color1) - np.array(color2))**2))
35
35
 
36
36
 
37
- # Legacy land cover classes mapping - kept for reference
37
+ # Standard land cover classes mapping (1-based indices for voxel representation)
38
38
  # land_cover_classes = {
39
- # (128, 0, 0): 'Bareland', 0
40
- # (0, 255, 36): 'Rangeland', 1
41
- # (97, 140, 86): 'Shrub', 2
42
- # (75, 181, 73): 'Agriculture land', 3
43
- # (34, 97, 38): 'Tree', 4
44
- # (77, 118, 99): 'Wet land', 5
45
- # (22, 61, 51): 'Mangrove', 6
46
- # (0, 69, 255): 'Water', 7
47
- # (205, 215, 224): 'Snow and ice', 8
48
- # (148, 148, 148): 'Developed space', 9
49
- # (255, 255, 255): 'Road', 10
50
- # (222, 31, 7): 'Building', 11
51
- # (128, 0, 0): 'No Data', 12
39
+ # (128, 0, 0): 'Bareland', 1
40
+ # (0, 255, 36): 'Rangeland', 2
41
+ # (97, 140, 86): 'Shrub', 3
42
+ # (75, 181, 73): 'Agriculture land', 4
43
+ # (34, 97, 38): 'Tree', 5
44
+ # (255, 255, 0): 'Moss and lichen', 6
45
+ # (77, 118, 99): 'Wet land', 7
46
+ # (22, 61, 51): 'Mangrove', 8
47
+ # (0, 69, 255): 'Water', 9
48
+ # (205, 215, 224): 'Snow and ice', 10
49
+ # (148, 148, 148): 'Developed space', 11
50
+ # (255, 255, 255): 'Road', 12
51
+ # (222, 31, 7): 'Building', 13
52
+ # (128, 0, 0): 'No Data', 14
52
53
  # }
53
54
 
54
55
  def get_land_cover_classes(source):
@@ -161,22 +162,58 @@ def get_land_cover_classes(source):
161
162
  }
162
163
  return land_cover_classes
163
164
 
164
- # Legacy land cover classes with numeric indices - kept for reference
165
+
166
+ def get_source_class_descriptions(source):
167
+ """
168
+ Get a formatted string describing land cover classes for a specific source.
169
+
170
+ Args:
171
+ source (str): Name of the land cover data source.
172
+
173
+ Returns:
174
+ str: Formatted string describing the source's land cover classes.
175
+ """
176
+ land_cover_classes = get_land_cover_classes(source)
177
+ # Get unique class names (values from the dict)
178
+ class_names = list(dict.fromkeys(land_cover_classes.values()))
179
+
180
+ lines = [f"\n{source} Land Cover Classes (source-specific, 0-based indices):"]
181
+ lines.append("-" * 55)
182
+ for idx, name in enumerate(class_names):
183
+ lines.append(f" {idx:2d}: {name}")
184
+ lines.append("-" * 55)
185
+
186
+ # Special note for OpenStreetMap/Standard which uses same class names
187
+ if source in ("OpenStreetMap", "Standard"):
188
+ lines.append("Note: OpenStreetMap uses VoxCity Standard class names.")
189
+ lines.append(" Indices shift from 0-based to 1-based during voxelization.")
190
+ else:
191
+ lines.append("Note: These source-specific classes will be converted to")
192
+ lines.append(" VoxCity Standard Classes (1-14) during voxelization.")
193
+
194
+ lines.append("")
195
+ lines.append("Access 2D land cover grid from VoxCity object:")
196
+ lines.append(" land_cover_grid = voxcity.land_cover.classes")
197
+ lines.append("")
198
+ return "\n".join(lines)
199
+
200
+
201
+ # Standard land cover classes with numeric indices (1-based for voxel representation)
165
202
  # land_cover_classes = {
166
- # (128, 0, 0): 'Bareland', 0
167
- # (0, 255, 36): 'Rangeland', 1
168
- # (97, 140, 86): 'Shrub', 2
169
- # (75, 181, 73): 'Agriculture land', 3
170
- # (34, 97, 38): 'Tree', 4
171
- # (34, 97, 38): 'Moss and lichen', 5
172
- # (77, 118, 99): 'Wet land', 6
173
- # (22, 61, 51): 'Mangrove', 7
174
- # (0, 69, 255): 'Water', 8
175
- # (205, 215, 224): 'Snow and ice', 9
176
- # (148, 148, 148): 'Developed space', 10
177
- # (255, 255, 255): 'Road', 11
178
- # (222, 31, 7): 'Building', 12
179
- # (128, 0, 0): 'No Data', 13
203
+ # (128, 0, 0): 'Bareland', 1
204
+ # (0, 255, 36): 'Rangeland', 2
205
+ # (97, 140, 86): 'Shrub', 3
206
+ # (75, 181, 73): 'Agriculture land', 4
207
+ # (34, 97, 38): 'Tree', 5
208
+ # (255, 255, 0): 'Moss and lichen', 6
209
+ # (77, 118, 99): 'Wet land', 7
210
+ # (22, 61, 51): 'Mangrove', 8
211
+ # (0, 69, 255): 'Water', 9
212
+ # (205, 215, 224): 'Snow and ice', 10
213
+ # (148, 148, 148): 'Developed space', 11
214
+ # (255, 255, 255): 'Road', 12
215
+ # (222, 31, 7): 'Building', 13
216
+ # (128, 0, 0): 'No Data', 14
180
217
  # }
181
218
 
182
219
 
@@ -185,21 +222,23 @@ def convert_land_cover(input_array, land_cover_source='Urbanwatch'):
185
222
  """
186
223
  Optimized version using direct numpy array indexing instead of np.vectorize.
187
224
  This is 10-100x faster than the original.
225
+
226
+ Returns 1-based class indices (1-14) for consistency with voxel representations.
188
227
  """
189
- # Define mappings
228
+ # Define mappings (1-based indices: 1=Bareland, 2=Rangeland, ..., 14=No Data)
190
229
  if land_cover_source == 'Urbanwatch':
191
- mapping = {0: 12, 1: 11, 2: 10, 3: 4, 4: 1, 5: 3, 6: 8, 7: 0, 8: 13, 9: 8}
230
+ mapping = {0: 13, 1: 12, 2: 11, 3: 5, 4: 2, 5: 4, 6: 9, 7: 1, 8: 14, 9: 9}
192
231
  elif land_cover_source == 'ESA WorldCover':
193
- mapping = {0: 4, 1: 2, 2: 1, 3: 3, 4: 10, 5: 0, 6: 9, 7: 8, 8: 6, 9: 7, 10: 5}
232
+ mapping = {0: 5, 1: 3, 2: 2, 3: 4, 4: 11, 5: 1, 6: 10, 7: 9, 8: 7, 9: 8, 10: 6}
194
233
  elif land_cover_source == "ESRI 10m Annual Land Cover":
195
- mapping = {0: 13, 1: 8, 2: 4, 3: 1, 4: 6, 5: 3, 6: 2, 7: 10, 8: 0, 9: 9, 10: 13}
234
+ mapping = {0: 14, 1: 9, 2: 5, 3: 2, 4: 7, 5: 4, 6: 3, 7: 11, 8: 1, 9: 10, 10: 14}
196
235
  elif land_cover_source == "Dynamic World V1":
197
- mapping = {0: 8, 1: 4, 2: 1, 3: 6, 4: 3, 5: 2, 6: 10, 7: 0, 8: 9}
236
+ mapping = {0: 9, 1: 5, 2: 2, 3: 7, 4: 4, 5: 3, 6: 11, 7: 1, 8: 10}
198
237
  elif land_cover_source == "OpenEarthMapJapan":
199
- mapping = {0: 0, 1: 1, 2: 10, 3: 11, 4: 4, 5: 8, 6: 3, 7: 12}
238
+ mapping = {0: 1, 1: 2, 2: 11, 3: 12, 4: 5, 5: 9, 6: 4, 7: 13}
200
239
  else:
201
- # If unknown source, return as-is
202
- return input_array.copy()
240
+ # If unknown source, return as-is with +1 offset for consistency
241
+ return input_array.copy() + 1
203
242
 
204
243
  # Create a full mapping array for all possible values (0-255 for uint8)
205
244
  max_val = max(max(mapping.keys()), input_array.max()) + 1
@@ -374,12 +413,14 @@ def convert_land_cover_array(input_array, land_cover_classes):
374
413
  land_cover_classes (dict): Dictionary mapping RGB tuples to class names
375
414
 
376
415
  Returns:
377
- numpy.ndarray: Array with integer indices corresponding to land cover classes
416
+ numpy.ndarray: Array with 0-based integer indices corresponding to land cover classes
378
417
 
379
418
  Note:
380
419
  Classes not found in the mapping are assigned index -1
420
+ Indices are 0-based as source-specific indices. Use convert_land_cover() to
421
+ remap to standard 1-based indices for voxel representation.
381
422
  """
382
- # Create a mapping of class names to integers
423
+ # Create a mapping of class names to integers (0-based, source-specific order)
383
424
  class_to_int = {name: i for i, name in enumerate(land_cover_classes.values())}
384
425
 
385
426
  # Create a vectorized function to map string values to integers
@@ -0,0 +1,61 @@
1
+ """
2
+ Lightweight, centralized logging utilities for the voxcity package.
3
+
4
+ Usage:
5
+ from voxcity.utils.logging import get_logger
6
+ logger = get_logger(__name__)
7
+
8
+ Environment variables:
9
+ VOXCITY_LOG_LEVEL: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ from typing import Optional
17
+
18
+
19
+ _LEVEL_NAMES = {
20
+ "CRITICAL": logging.CRITICAL,
21
+ "ERROR": logging.ERROR,
22
+ "WARNING": logging.WARNING,
23
+ "INFO": logging.INFO,
24
+ "DEBUG": logging.DEBUG,
25
+ }
26
+
27
+
28
+ def _resolve_level(env_value: Optional[str]) -> int:
29
+ if not env_value:
30
+ return logging.INFO
31
+ return _LEVEL_NAMES.get(env_value.strip().upper(), logging.INFO)
32
+
33
+
34
+ def _configure_root_once() -> None:
35
+ root = logging.getLogger("voxcity")
36
+ if root.handlers:
37
+ return
38
+ level = _resolve_level(os.getenv("VOXCITY_LOG_LEVEL"))
39
+ root.setLevel(level)
40
+ handler = logging.StreamHandler()
41
+ handler.setLevel(level)
42
+ formatter = logging.Formatter(
43
+ fmt="%(levelname)s | %(name)s | %(message)s",
44
+ )
45
+ handler.setFormatter(formatter)
46
+ root.addHandler(handler)
47
+ # Prevent duplicate messages from propagating to the global root logger
48
+ root.propagate = False
49
+
50
+
51
+ def get_logger(name: Optional[str] = None) -> logging.Logger:
52
+ """Return a child logger under the package root logger.
53
+
54
+ - Ensures a single configuration for the package
55
+ - Respects VOXCITY_LOG_LEVEL if set
56
+ """
57
+ _configure_root_once()
58
+ pkg_logger = logging.getLogger("voxcity")
59
+ return pkg_logger.getChild(name) if name else pkg_logger
60
+
61
+
@@ -0,0 +1,51 @@
1
+ """Grid orientation helpers.
2
+
3
+ Contract:
4
+ - Canonical internal orientation is "north_up": row 0 is the northern/top row,
5
+ increasing row index moves south/down. Columns increase eastward: column 0 is
6
+ west/left and indices increase toward the east/right. All processing functions
7
+ accept and return 2D grids in this orientation unless explicitly documented
8
+ otherwise.
9
+ - Visualization utilities may flip vertically for display purposes only.
10
+ - 3D indexing follows (row, col, z) = (north→south, west→east, ground→up).
11
+
12
+ Utilities here are intentionally minimal to avoid introducing hidden behavior.
13
+ They can be used at I/O boundaries (e.g., when reading rasters with south_up
14
+ conventions) to normalize to the internal orientation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Literal
20
+ import numpy as np
21
+
22
+ # Public constants to reference orientation in docs and code
23
+ ORIENTATION_NORTH_UP: Literal["north_up"] = "north_up"
24
+ ORIENTATION_SOUTH_UP: Literal["south_up"] = "south_up"
25
+
26
+
27
+ def ensure_orientation(
28
+ grid: np.ndarray,
29
+ orientation_in: Literal["north_up", "south_up"],
30
+ orientation_out: Literal["north_up", "south_up"] = ORIENTATION_NORTH_UP,
31
+ ) -> np.ndarray:
32
+ """Return ``grid`` converted from ``orientation_in`` to ``orientation_out``.
33
+
34
+ Both orientations are defined for 2D arrays as:
35
+ - north_up: row 0 = north/top, last row = south/bottom
36
+ - south_up: row 0 = south/bottom, last row = north/top
37
+
38
+ If orientations match, the input array is returned unchanged. When converting
39
+ between north_up and south_up, a vertical flip is applied using ``np.flipud``.
40
+
41
+ Notes
42
+ -----
43
+ - This function does not copy when no conversion is needed.
44
+ - Use at data boundaries (read/write, interop) rather than deep in processing code.
45
+ """
46
+ if orientation_in == orientation_out:
47
+ return grid
48
+ # Only two orientations supported; converting between them is a vertical flip
49
+ return np.flipud(grid)
50
+
51
+