0.1.8
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
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
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 getNativePtr(self):
140 """Get the native pointer for advanced operations."""
141 return self.energy_model
143 def enableMessages(self) -> None:
144 """
145 Enable console output messages from the energy balance model.
146
147 Raises:
148 EnergyBalanceModelError: If operation fails
149 """
150 try:
151 energy_wrapper.enableMessages(self.energy_model)
152 except Exception as e:
153 raise EnergyBalanceModelError(f"Failed to enable messages: {e}")
154
155 def disableMessages(self) -> None:
156 """
157 Disable console output messages from the energy balance model.
158
159 Raises:
160 EnergyBalanceModelError: If operation fails
161 """
162 try:
163 energy_wrapper.disableMessages(self.energy_model)
164 except Exception as e:
165 raise EnergyBalanceModelError(f"Failed to disable messages: {e}")
166
167 @validate_energy_run_params
168 def run(self, uuids: Optional[List[int]] = None, dt: Optional[float] = None) -> None:
169 """
170 Run the energy balance model.
171
172 This method supports multiple execution modes:
173 - Steady state for all primitives: run()
174 - Dynamic with timestep for all primitives: run(dt=60.0)
175 - Steady state for specific primitives: run(uuids=[1, 2, 3])
176 - Dynamic with timestep for specific primitives: run(uuids=[1, 2, 3], dt=60.0)
177
178 Args:
179 uuids: Optional list of primitive UUIDs to process. If None, processes all primitives.
180 dt: Optional timestep in seconds for dynamic simulation. If None, runs steady-state.
181
182 Raises:
183 ValueError: If parameters are invalid
184 EnergyBalanceModelError: If energy balance calculation fails
185
186 Example:
187 >>> # Steady state for all primitives
188 >>> energy_balance.run()
189
190 >>> # Dynamic simulation with 60-second timestep
191 >>> energy_balance.run(dt=60.0)
192
193 >>> # Steady state for specific patches
194 >>> energy_balance.run(uuids=[patch1_uuid, patch2_uuid])
195
196 >>> # Dynamic simulation for specific patches
197 >>> energy_balance.run(uuids=[patch1_uuid, patch2_uuid], dt=30.0)
198 """
199 try:
200 if uuids is None and dt is None:
201 # Steady state for all primitives
202 energy_wrapper.run(self.energy_model)
203 elif uuids is None and dt is not None:
204 # Dynamic with timestep for all primitives
205 energy_wrapper.runDynamic(self.energy_model, dt)
206 elif uuids is not None and dt is None:
207 # Steady state for specific primitives
208 energy_wrapper.runForUUIDs(self.energy_model, uuids)
209 else:
210 # Dynamic with timestep for specific primitives
211 energy_wrapper.runForUUIDsDynamic(self.energy_model, uuids, dt)
212
213 except Exception as e:
214 raise EnergyBalanceModelError(f"Energy balance calculation failed: {e}")
215
216 @validate_energy_band_params
217 def addRadiationBand(self, band: Union[str, List[str]]) -> None:
218 """
219 Add a radiation band or bands for absorbed flux calculations.
220
221 The energy balance model uses radiation bands from the RadiationModel
222 plugin to calculate absorbed radiation flux for each primitive.
223
224 Args:
225 band: Name of radiation band (e.g., "SW", "PAR", "NIR", "LW")
226 or list of band names
227
228 Raises:
229 ValueError: If band name is invalid
230 EnergyBalanceModelError: If operation fails
231
232 Example:
233 >>> energy_balance.addRadiationBand("SW") # Single band
234 >>> energy_balance.addRadiationBand(["SW", "LW", "PAR"]) # Multiple bands
235 """
236 if isinstance(band, str):
237 if not band:
238 raise ValueError("Band name must be a non-empty string")
239 try:
240 energy_wrapper.addRadiationBand(self.energy_model, band)
241 except Exception as e:
242 raise EnergyBalanceModelError(f"Failed to add radiation band '{band}': {e}")
243 elif isinstance(band, list):
244 if not band:
245 raise ValueError("Bands list cannot be empty")
246 for b in band:
247 if not isinstance(b, str) or not b:
248 raise ValueError("All band names must be non-empty strings")
249 try:
250 energy_wrapper.addRadiationBands(self.energy_model, band)
251 except Exception as e:
252 raise EnergyBalanceModelError(f"Failed to add radiation bands {band}: {e}")
253 else:
254 raise ValueError("Band must be a string or list of strings")
255
256 @validate_air_energy_params
257 def enableAirEnergyBalance(self, canopy_height_m: Optional[float] = None,
258 reference_height_m: Optional[float] = None) -> None:
259 """
260 Enable air energy balance model for canopy-scale thermal calculations.
261
262 The air energy balance computes average air temperature and water vapor
263 mole fraction based on the energy balance of the air layer in the canopy.
264
265 Args:
266 canopy_height_m: Optional canopy height in meters. If not provided,
267 computed automatically from primitive bounding box.
268 reference_height_m: Optional reference height in meters where ambient
269 conditions are measured. If not provided, assumes
270 reference height is at canopy top.
271
272 Raises:
273 ValueError: If parameters are invalid
274 EnergyBalanceModelError: If operation fails
275
276 Example:
277 >>> # Automatic canopy height detection
278 >>> energy_balance.enable_air_energy_balance()
279
280 >>> # Manual canopy and reference heights
281 >>> energy_balance.enable_air_energy_balance(canopy_height_m=5.0, reference_height_m=10.0)
282 """
283 if canopy_height_m is not None and canopy_height_m <= 0:
284 raise ValueError("Canopy height must be positive")
285 if reference_height_m is not None and reference_height_m <= 0:
286 raise ValueError("Reference height must be positive")
287
288 try:
289 if canopy_height_m is None and reference_height_m is None:
290 energy_wrapper.enableAirEnergyBalance(self.energy_model)
291 elif canopy_height_m is not None and reference_height_m is not None:
292 energy_wrapper.enableAirEnergyBalanceWithParameters(
293 self.energy_model, canopy_height_m, reference_height_m)
294 else:
295 raise ValueError("Both canopy_height_m and reference_height_m must be provided together, or both None")
296
297 except Exception as e:
298 raise EnergyBalanceModelError(f"Failed to enable air energy balance: {e}")
299
300 @validate_evaluate_air_energy_params
301 def evaluateAirEnergyBalance(self, dt_sec: float, time_advance_sec: float,
302 UUIDs: Optional[List[int]] = None) -> None:
303 """
304 Advance the air energy balance over time.
305
306 This method advances the air energy balance model by integrating over
307 multiple timesteps to reach the target time advancement.
308
309 Args:
310 dt_sec: Timestep in seconds for integration
311 time_advance_sec: Total time to advance in seconds (must be >= dt_sec)
312 uuids: Optional list of primitive UUIDs. If None, processes all primitives.
313
314 Raises:
315 ValueError: If parameters are invalid
316 EnergyBalanceModelError: If operation fails
317
318 Example:
319 >>> # Advance air energy balance by 1 hour using 60-second timesteps
320 >>> energy_balance.evaluate_air_energy_balance(dt_sec=60.0, time_advance_sec=3600.0)
321
322 >>> # Advance for specific primitives
323 >>> energy_balance.evaluate_air_energy_balance(
324 ... dt_sec=30.0, time_advance_sec=1800.0, uuids=[patch1_uuid, patch2_uuid])
325 """
326 if dt_sec <= 0:
327 raise ValueError("Time step must be positive")
328 if time_advance_sec < dt_sec:
329 raise ValueError("Total time advance must be greater than or equal to time step")
330
331 try:
332 if UUIDs is None:
333 energy_wrapper.evaluateAirEnergyBalance(self.energy_model, dt_sec, time_advance_sec)
334 else:
335 energy_wrapper.evaluateAirEnergyBalanceForUUIDs(
336 self.energy_model, UUIDs, dt_sec, time_advance_sec)
337
338 except Exception as e:
339 raise EnergyBalanceModelError(f"Failed to evaluate air energy balance: {e}")
340
341 @validate_output_data_params
342 def optionalOutputPrimitiveData(self, label: str) -> None:
343 """
344 Add optional output primitive data to the Context.
345
346 This method adds additional data fields to primitives that will be
347 calculated and stored during energy balance execution.
348
349 Args:
350 label: Name of the data field to add (e.g., "vapor_pressure_deficit",
351 "boundary_layer_conductance", "net_radiation")
352
353 Raises:
354 ValueError: If label is invalid
355 EnergyBalanceModelError: If operation fails
356
357 Example:
358 >>> energy_balance.add_optional_output_data("vapor_pressure_deficit")
359 >>> energy_balance.add_optional_output_data("net_radiation")
360 """
361 if not label or not isinstance(label, str):
362 raise ValueError("Label must be a non-empty string")
363
364 try:
365 energy_wrapper.optionalOutputPrimitiveData(self.energy_model, label)
366 except Exception as e:
367 raise EnergyBalanceModelError(f"Failed to add optional output data '{label}': {e}")
368
369 @validate_print_report_params
370 def printDefaultValueReport(self, UUIDs: Optional[List[int]] = None) -> None:
371 """
372 Print a report detailing usage of default input values.
373
374 This diagnostic method prints information about which primitives are
375 using default values for energy balance parameters, helping identify
376 where additional parameter specification might be needed.
377
378 Args:
379 uuids: Optional list of primitive UUIDs to report on. If None,
380 reports on all primitives.
381
382 Raises:
383 EnergyBalanceModelError: If operation fails
384
385 Example:
386 >>> # Report on all primitives
387 >>> energy_balance.print_default_value_report()
388
389 >>> # Report on specific primitives
390 >>> energy_balance.print_default_value_report(uuids=[patch1_uuid, patch2_uuid])
391 """
392 try:
393 if UUIDs is None:
394 energy_wrapper.printDefaultValueReport(self.energy_model)
395 else:
396 energy_wrapper.printDefaultValueReportForUUIDs(self.energy_model, UUIDs)
397
398 except Exception as e:
399 raise EnergyBalanceModelError(f"Failed to print default value report: {e}")
400
401 def is_available(self) -> bool:
402 """
403 Check if EnergyBalanceModel is available in current build.
404
405 Returns:
406 True if plugin is available, False otherwise
407 """
408 registry = get_plugin_registry()
409 return registry.is_plugin_available('energybalance')
410
411
412# Convenience function
413def create_energy_balance_model(context: Context) -> EnergyBalanceModel:
414 """
415 Create EnergyBalanceModel instance with context.
416
417 Args:
418 context: Helios Context
419
420 Returns:
421 EnergyBalanceModel instance
422 """
423 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.
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.