PyHelios 0.1.11
Loading...
Searching...
No Matches
EnergyBalance.py
Go to the documentation of this file.
1"""
2High-level EnergyBalance interface for PyHelios.
3
4This module provides a user-friendly interface to the energy balance modeling
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import logging
9from typing import List, Optional, Union
10from contextlib import contextmanager
11
12from .plugins.registry import get_plugin_registry
13from .wrappers import UEnergyBalanceWrapper as energy_wrapper
14from .Context import Context
15from .exceptions import HeliosError
16from .validation.plugin_decorators import (
17 validate_energy_run_params, validate_energy_band_params, validate_air_energy_params,
18 validate_evaluate_air_energy_params, validate_output_data_params, validate_print_report_params
19)
20
21logger = logging.getLogger(__name__)
22
23
25 """Exception raised for EnergyBalance-specific errors."""
26 pass
27
28
30 """
31 High-level interface for energy balance modeling and thermal calculations.
32
33 This class provides a user-friendly wrapper around the native Helios
34 energy balance plugin with automatic plugin availability checking and
35 graceful error handling.
36
37 The energy balance model computes surface temperatures based on local energy
38 balance equations, including radiation absorption, convection, and transpiration.
39 It supports both steady-state and dynamic (time-stepping) calculations.
40
41 System requirements:
42 - NVIDIA GPU with CUDA support
43 - CUDA Toolkit installed
44 - Energy balance plugin compiled into PyHelios
45
46 Example:
47 >>> with Context() as context:
48 ... # Add some geometry
49 ... patch_uuid = context.addPatch(center=[0, 0, 1], size=[1, 1])
50 ...
51 ... with EnergyBalanceModel(context) as energy_balance:
52 ... # Add radiation band for flux calculations
53 ... energy_balance.addRadiationBand("SW")
54 ...
55 ... # Run steady-state energy balance
56 ... energy_balance.run()
57 ...
58 ... # Or run dynamic simulation with timestep
59 ... energy_balance.run(dt=60.0) # 60 second timestep
60 """
61
62 def __init__(self, context: Context):
63 """
64 Initialize EnergyBalanceModel with graceful plugin handling.
65
66 Args:
67 context: Helios Context instance
68
69 Raises:
70 TypeError: If context is not a Context instance
71 EnergyBalanceModelError: If energy balance plugin is not available
72 """
73 # Validate context type
74 if not isinstance(context, Context):
75 raise TypeError(f"EnergyBalanceModel requires a Context instance, got {type(context).__name__}")
76
77 self.context = context
78 self.energy_model = None
79
80 # Check plugin availability using registry
81 registry = get_plugin_registry()
82
83 if not registry.is_plugin_available('energybalance'):
84 # Get helpful information about the missing plugin
85 plugin_info = registry.get_plugin_capabilities()
86 available_plugins = registry.get_available_plugins()
87
88 error_msg = (
89 "EnergyBalanceModel requires the 'energybalance' plugin which is not available.\n\n"
90 "The energy balance plugin provides GPU-accelerated thermal modeling and surface temperature calculations.\n"
91 "System requirements:\n"
92 "- NVIDIA GPU with CUDA support\n"
93 "- CUDA Toolkit installed\n"
94 "- Energy balance plugin compiled into PyHelios\n\n"
95 "To enable energy balance modeling:\n"
96 "1. Build PyHelios with energy balance plugin:\n"
97 " build_scripts/build_helios --plugins energybalance\n"
98 "2. Or build with multiple plugins:\n"
99 " build_scripts/build_helios --plugins energybalance,visualizer,weberpenntree\n"
100 f"\nCurrently available plugins: {available_plugins}"
101 )
102
103 # Suggest alternatives if available
104 alternatives = registry.suggest_alternatives('energybalance')
105 if alternatives:
106 error_msg += f"\n\nAlternative plugins available: {alternatives}"
107 error_msg += "\nConsider using radiation or photosynthesis for related thermal modeling."
108
109 raise EnergyBalanceModelError(error_msg)
110
111 # Plugin is available - create energy balance model
112 try:
113 self.energy_model = energy_wrapper.createEnergyBalanceModel(context.getNativePtr())
114 if self.energy_model is None:
116 "Failed to create EnergyBalanceModel instance. "
117 "This may indicate a problem with the native library or CUDA initialization."
118 )
119 logger.info("EnergyBalanceModel created successfully")
120
121 except Exception as e:
122 raise EnergyBalanceModelError(f"Failed to initialize EnergyBalanceModel: {e}")
123
124 def __enter__(self):
125 """Context manager entry."""
126 return self
127
128 def __exit__(self, exc_type, exc_value, traceback):
129 """Context manager exit with proper cleanup."""
130 if self.energy_model is not None:
131 try:
132 energy_wrapper.destroyEnergyBalanceModel(self.energy_model)
133 logger.debug("EnergyBalanceModel destroyed successfully")
134 except Exception as e:
135 logger.warning(f"Error destroying EnergyBalanceModel: {e}")
136 finally:
137 self.energy_model = None
138
139 def __del__(self):
140 """Destructor to ensure C++ resources freed even without 'with' statement."""
141 if hasattr(self, 'energy_model') and self.energy_model is not None:
142 try:
143 energy_wrapper.destroyEnergyBalanceModel(self.energy_model)
144 self.energy_model = None
145 except Exception as e:
146 import warnings
147 warnings.warn(f"Error in EnergyBalanceModel.__del__: {e}")
148
149 def getNativePtr(self):
150 """Get the native pointer for advanced operations."""
151 return self.energy_model
152
153 def enableMessages(self) -> None:
154 """
155 Enable console output messages from the energy balance model.
156
157 Raises:
158 EnergyBalanceModelError: If operation fails
159 """
160 try:
161 energy_wrapper.enableMessages(self.energy_model)
162 except Exception as e:
163 raise EnergyBalanceModelError(f"Failed to enable messages: {e}")
164
165 def disableMessages(self) -> None:
166 """
167 Disable console output messages from the energy balance model.
168
169 Raises:
170 EnergyBalanceModelError: If operation fails
171 """
172 try:
173 energy_wrapper.disableMessages(self.energy_model)
174 except Exception as e:
175 raise EnergyBalanceModelError(f"Failed to disable messages: {e}")
176
177 @validate_energy_run_params
178 def run(self, uuids: Optional[List[int]] = None, dt: Optional[float] = None) -> None:
179 """
180 Run the energy balance model.
181
182 This method supports multiple execution modes:
183 - Steady state for all primitives: run()
184 - Dynamic with timestep for all primitives: run(dt=60.0)
185 - Steady state for specific primitives: run(uuids=[1, 2, 3])
186 - Dynamic with timestep for specific primitives: run(uuids=[1, 2, 3], dt=60.0)
187
188 Args:
189 uuids: Optional list of primitive UUIDs to process. If None, processes all primitives.
190 dt: Optional timestep in seconds for dynamic simulation. If None, runs steady-state.
191
192 Raises:
193 ValueError: If parameters are invalid
194 EnergyBalanceModelError: If energy balance calculation fails
195
196 Example:
197 >>> # Steady state for all primitives
198 >>> energy_balance.run()
199
200 >>> # Dynamic simulation with 60-second timestep
201 >>> energy_balance.run(dt=60.0)
202
203 >>> # Steady state for specific patches
204 >>> energy_balance.run(uuids=[patch1_uuid, patch2_uuid])
205
206 >>> # Dynamic simulation for specific patches
207 >>> energy_balance.run(uuids=[patch1_uuid, patch2_uuid], dt=30.0)
208 """
209 try:
210 if uuids is None and dt is None:
211 # Steady state for all primitives
212 energy_wrapper.run(self.energy_model)
213 elif uuids is None and dt is not None:
214 # Dynamic with timestep for all primitives
215 energy_wrapper.runDynamic(self.energy_model, dt)
216 elif uuids is not None and dt is None:
217 # Steady state for specific primitives
218 energy_wrapper.runForUUIDs(self.energy_model, uuids)
219 else:
220 # Dynamic with timestep for specific primitives
221 energy_wrapper.runForUUIDsDynamic(self.energy_model, uuids, dt)
222
223 except Exception as e:
224 raise EnergyBalanceModelError(f"Energy balance calculation failed: {e}")
225
226 @validate_energy_band_params
227 def addRadiationBand(self, band: Union[str, List[str]]) -> None:
228 """
229 Add a radiation band or bands for absorbed flux calculations.
230
231 The energy balance model uses radiation bands from the RadiationModel
232 plugin to calculate absorbed radiation flux for each primitive.
233
234 Args:
235 band: Name of radiation band (e.g., "SW", "PAR", "NIR", "LW")
236 or list of band names
237
238 Raises:
239 ValueError: If band name is invalid
240 EnergyBalanceModelError: If operation fails
241
242 Example:
243 >>> energy_balance.addRadiationBand("SW") # Single band
244 >>> energy_balance.addRadiationBand(["SW", "LW", "PAR"]) # Multiple bands
245 """
246 if isinstance(band, str):
247 if not band:
248 raise ValueError("Band name must be a non-empty string")
249 try:
250 energy_wrapper.addRadiationBand(self.energy_model, band)
251 except Exception as e:
252 raise EnergyBalanceModelError(f"Failed to add radiation band '{band}': {e}")
253 elif isinstance(band, list):
254 if not band:
255 raise ValueError("Bands list cannot be empty")
256 for b in band:
257 if not isinstance(b, str) or not b:
258 raise ValueError("All band names must be non-empty strings")
259 try:
260 energy_wrapper.addRadiationBands(self.energy_model, band)
261 except Exception as e:
262 raise EnergyBalanceModelError(f"Failed to add radiation bands {band}: {e}")
263 else:
264 raise ValueError("Band must be a string or list of strings")
265
266 @validate_air_energy_params
267 def enableAirEnergyBalance(self, canopy_height_m: Optional[float] = None,
268 reference_height_m: Optional[float] = None) -> None:
269 """
270 Enable air energy balance model for canopy-scale thermal calculations.
271
272 The air energy balance computes average air temperature and water vapor
273 mole fraction based on the energy balance of the air layer in the canopy.
274
275 Args:
276 canopy_height_m: Optional canopy height in meters. If not provided,
277 computed automatically from primitive bounding box.
278 reference_height_m: Optional reference height in meters where ambient
279 conditions are measured. If not provided, assumes
280 reference height is at canopy top.
281
282 Raises:
283 ValueError: If parameters are invalid
284 EnergyBalanceModelError: If operation fails
285
286 Example:
287 >>> # Automatic canopy height detection
288 >>> energy_balance.enable_air_energy_balance()
289
290 >>> # Manual canopy and reference heights
291 >>> energy_balance.enable_air_energy_balance(canopy_height_m=5.0, reference_height_m=10.0)
292 """
293 if canopy_height_m is not None and canopy_height_m <= 0:
294 raise ValueError("Canopy height must be positive")
295 if reference_height_m is not None and reference_height_m <= 0:
296 raise ValueError("Reference height must be positive")
297
298 try:
299 if canopy_height_m is None and reference_height_m is None:
300 energy_wrapper.enableAirEnergyBalance(self.energy_model)
301 elif canopy_height_m is not None and reference_height_m is not None:
302 energy_wrapper.enableAirEnergyBalanceWithParameters(
303 self.energy_model, canopy_height_m, reference_height_m)
304 else:
305 raise ValueError("Both canopy_height_m and reference_height_m must be provided together, or both None")
306
307 except Exception as e:
308 raise EnergyBalanceModelError(f"Failed to enable air energy balance: {e}")
309
310 @validate_evaluate_air_energy_params
311 def evaluateAirEnergyBalance(self, dt_sec: float, time_advance_sec: float,
312 UUIDs: Optional[List[int]] = None) -> None:
313 """
314 Advance the air energy balance over time.
315
316 This method advances the air energy balance model by integrating over
317 multiple timesteps to reach the target time advancement.
318
319 Args:
320 dt_sec: Timestep in seconds for integration
321 time_advance_sec: Total time to advance in seconds (must be >= dt_sec)
322 UUIDs: Optional list of primitive UUIDs. If None, processes all primitives.
323
324 Raises:
325 ValueError: If parameters are invalid
326 EnergyBalanceModelError: If operation fails
327
328 Example:
329 >>> # Advance air energy balance by 1 hour using 60-second timesteps
330 >>> energy_balance.evaluate_air_energy_balance(dt_sec=60.0, time_advance_sec=3600.0)
331
332 >>> # Advance for specific primitives
333 >>> energy_balance.evaluate_air_energy_balance(
334 ... dt_sec=30.0, time_advance_sec=1800.0, uuids=[patch1_uuid, patch2_uuid])
335 """
336 if dt_sec <= 0:
337 raise ValueError("Time step must be positive")
338 if time_advance_sec < dt_sec:
339 raise ValueError("Total time advance must be greater than or equal to time step")
340
341 try:
342 if UUIDs is None:
343 energy_wrapper.evaluateAirEnergyBalance(self.energy_model, dt_sec, time_advance_sec)
344 else:
345 energy_wrapper.evaluateAirEnergyBalanceForUUIDs(
346 self.energy_model, UUIDs, dt_sec, time_advance_sec)
347
348 except Exception as e:
349 raise EnergyBalanceModelError(f"Failed to evaluate air energy balance: {e}")
350
351 @validate_output_data_params
352 def optionalOutputPrimitiveData(self, label: str) -> None:
353 """
354 Add optional output primitive data to the Context.
355
356 This method adds additional data fields to primitives that will be
357 calculated and stored during energy balance execution.
358
359 Args:
360 label: Name of the data field to add (e.g., "vapor_pressure_deficit",
361 "boundary_layer_conductance", "net_radiation")
362
363 Raises:
364 ValueError: If label is invalid
365 EnergyBalanceModelError: If operation fails
366
367 Example:
368 >>> energy_balance.add_optional_output_data("vapor_pressure_deficit")
369 >>> energy_balance.add_optional_output_data("net_radiation")
370 """
371 if not label or not isinstance(label, str):
372 raise ValueError("Label must be a non-empty string")
373
374 try:
375 energy_wrapper.optionalOutputPrimitiveData(self.energy_model, label)
376 except Exception as e:
377 raise EnergyBalanceModelError(f"Failed to add optional output data '{label}': {e}")
378
379 @validate_print_report_params
380 def printDefaultValueReport(self, UUIDs: Optional[List[int]] = None) -> None:
381 """
382 Print a report detailing usage of default input values.
383
384 This diagnostic method prints information about which primitives are
385 using default values for energy balance parameters, helping identify
386 where additional parameter specification might be needed.
387
388 Args:
389 UUIDs: Optional list of primitive UUIDs to report on. If None,
390 reports on all primitives.
391
392 Raises:
393 EnergyBalanceModelError: If operation fails
394
395 Example:
396 >>> # Report on all primitives
397 >>> energy_balance.print_default_value_report()
398
399 >>> # Report on specific primitives
400 >>> energy_balance.print_default_value_report(uuids=[patch1_uuid, patch2_uuid])
401 """
402 try:
403 if UUIDs is None:
404 energy_wrapper.printDefaultValueReport(self.energy_model)
405 else:
406 energy_wrapper.printDefaultValueReportForUUIDs(self.energy_model, UUIDs)
407
408 except Exception as e:
409 raise EnergyBalanceModelError(f"Failed to print default value report: {e}")
410
411 def is_available(self) -> bool:
412 """
413 Check if EnergyBalanceModel is available in current build.
414
415 Returns:
416 True if plugin is available, False otherwise
417 """
418 registry = get_plugin_registry()
419 return registry.is_plugin_available('energybalance')
420
421
422# Convenience function
423def create_energy_balance_model(context: Context) -> EnergyBalanceModel:
424 """
425 Create EnergyBalanceModel instance with context.
426
427 Args:
428 context: Helios Context
429
430 Returns:
431 EnergyBalanceModel instance
432 """
433 return EnergyBalanceModel(context)
Exception raised for EnergyBalance-specific errors.
High-level interface for energy balance modeling and thermal calculations.
__init__(self, Context context)
Initialize EnergyBalanceModel with graceful plugin handling.
None optionalOutputPrimitiveData(self, str label)
Add optional output primitive data to the Context.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
bool is_available(self)
Check if EnergyBalanceModel is available in current build.
None disableMessages(self)
Disable console output messages from the energy balance model.
None enableMessages(self)
Enable console output messages from the energy balance model.
None run(self, Optional[List[int]] uuids=None, Optional[float] dt=None)
Run the energy balance model.
None printDefaultValueReport(self, Optional[List[int]] UUIDs=None)
Print a report detailing usage of default input values.
__enter__(self)
Context manager entry.
None addRadiationBand(self, Union[str, List[str]] band)
Add a radiation band or bands for absorbed flux calculations.
__exit__(self, exc_type, exc_value, traceback)
Context manager exit with proper cleanup.
getNativePtr(self)
Get the native pointer for advanced operations.
None evaluateAirEnergyBalance(self, float dt_sec, float time_advance_sec, Optional[List[int]] UUIDs=None)
Advance the air energy balance over time.
None enableAirEnergyBalance(self, Optional[float] canopy_height_m=None, Optional[float] reference_height_m=None)
Enable air energy balance model for canopy-scale thermal calculations.
Exception classes for PyHelios library.
Definition exceptions.py:10
EnergyBalanceModel create_energy_balance_model(Context context)
Create EnergyBalanceModel instance with context.