PyHelios 0.1.11
Loading...
Searching...
No Matches
SolarPosition.py
Go to the documentation of this file.
1"""
2SolarPosition - High-level interface for solar position and radiation calculations
3
4This module provides a Python interface to the SolarPosition Helios plugin,
5offering comprehensive solar angle calculations, radiation modeling, and
6time-dependent solar functions for atmospheric physics and plant modeling.
7"""
8
9from typing import List, Tuple, Optional, Union
10from .wrappers import USolarPositionWrapper as solar_wrapper
11from .Context import Context
12from .plugins.registry import get_plugin_registry
13from .exceptions import HeliosError
14from .wrappers.DataTypes import Time, Date, vec3, SphericalCoord
15
16
18 """Exception raised for SolarPosition-specific errors"""
19 pass
20
21
22class SolarPosition:
23 """
24 High-level interface for solar position calculations and radiation modeling.
25
26 SolarPosition provides comprehensive solar angle calculations, radiation flux
27 modeling, sunrise/sunset time calculations, and atmospheric turbidity calibration.
28 The plugin automatically uses Context time/date for calculations or can be
29 initialized with explicit coordinates.
30
31 This class requires the native Helios library built with SolarPosition support.
32 Use context managers for proper resource cleanup.
33
34 Examples:
35 Basic usage with Context coordinates:
36 >>> with Context() as context:
37 ... context.setDate(2023, 6, 21) # Summer solstice
38 ... context.setTime(12, 0) # Solar noon
39 ... with SolarPosition(context) as solar:
40 ... elevation = solar.getSunElevation()
41 ... print(f"Sun elevation: {elevation:.1f}°")
42
43 Usage with explicit coordinates:
44 >>> with Context() as context:
45 ... # Davis, California coordinates
46 ... with SolarPosition(context, utc_offset=-8, latitude=38.5, longitude=-121.7) as solar:
47 ... azimuth = solar.getSunAzimuth()
48 ... flux = solar.getSolarFlux(101325, 288.15, 0.6, 0.1)
49 ... print(f"Solar flux: {flux:.1f} W/m²")
50 """
51
52 def __init__(self, context: Context, utc_offset: Optional[float] = None,
53 latitude: Optional[float] = None, longitude: Optional[float] = None):
54 """
55 Initialize SolarPosition with a Helios context.
56
57 Args:
58 context: Active Helios Context instance
59 utc_offset: UTC time offset in hours (-12 to +12). If provided with
60 latitude/longitude, creates plugin with explicit coordinates.
61 latitude: Latitude in degrees (-90 to +90). Required if utc_offset provided.
62 longitude: Longitude in degrees (-180 to +180). Required if utc_offset provided.
63
64 Raises:
65 SolarPositionError: If plugin not available in current build
66 ValueError: If coordinate parameters are invalid or incomplete
67 RuntimeError: If plugin initialization fails
68
69 Note:
70 If coordinates are not provided, the plugin uses Context location settings.
71 Solar calculations depend on Context time/date - use context.setTime() and
72 context.setDate() to set the simulation time before calculations.
73 """
74 # Check plugin availability
75 registry = get_plugin_registry()
76 if not registry.is_plugin_available('solarposition'):
78 "SolarPosition not available in current Helios library. "
79 "SolarPosition plugin availability depends on build configuration.\n"
80 "\n"
81 "System requirements:\n"
82 " - Platforms: Windows, Linux, macOS\n"
83 " - Dependencies: None\n"
84 " - GPU: Not required\n"
85 "\n"
86 "If you're seeing this error, the SolarPosition plugin may not be "
87 "properly compiled into your Helios library. Please rebuild PyHelios:\n"
88 " build_scripts/build_helios --clean"
89 )
90
91 # Validate coordinate parameters
92 if utc_offset is not None or latitude is not None or longitude is not None:
93 # If any coordinate parameter is provided, all must be provided
94 if utc_offset is None or latitude is None or longitude is None:
95 raise ValueError(
96 "If specifying coordinates, all three parameters must be provided: "
97 "utc_offset, latitude, longitude"
98 )
99
100 # Validate coordinate ranges
101 if utc_offset < -12.0 or utc_offset > 12.0:
102 raise ValueError(f"UTC offset must be between -12 and +12 hours, got: {utc_offset}")
103 if latitude < -90.0 or latitude > 90.0:
104 raise ValueError(f"Latitude must be between -90 and +90 degrees, got: {latitude}")
105 if longitude < -180.0 or longitude > 180.0:
106 raise ValueError(f"Longitude must be between -180 and +180 degrees, got: {longitude}")
107
108 # Create with explicit coordinates
109 self.context = context
110 self._solar_pos = solar_wrapper.createSolarPositionWithCoordinates(
111 context.getNativePtr(), utc_offset, latitude, longitude
112 )
113 else:
114 # Create using Context location
115 self.context = context
116 self._solar_pos = solar_wrapper.createSolarPosition(context.getNativePtr())
117
118 if not self._solar_pos:
119 raise SolarPositionError("Failed to initialize SolarPosition")
120
121 def __enter__(self):
122 """Context manager entry"""
123 return self
124
125 def __exit__(self, exc_type, exc_val, exc_tb):
126 """Context manager exit - cleanup resources"""
127 if hasattr(self, '_solar_pos') and self._solar_pos:
128 solar_wrapper.destroySolarPosition(self._solar_pos)
129 self._solar_pos = None
130
131 def __del__(self):
132 """Destructor to ensure C++ resources freed even without 'with' statement."""
133 if hasattr(self, '_solar_pos') and self._solar_pos is not None:
134 try:
135 solar_wrapper.destroySolarPosition(self._solar_pos)
136 self._solar_pos = None
137 except Exception as e:
138 import warnings
139 warnings.warn(f"Error in SolarPosition.__del__: {e}")
140
141 # Atmospheric condition management (modern API)
142 def setAtmosphericConditions(self, pressure_Pa: float, temperature_K: float,
143 humidity_rel: float, turbidity: float) -> None:
144 """
145 Set atmospheric conditions for subsequent flux calculations (modern API).
146
147 This method sets global atmospheric conditions in the Context that are used
148 by parameter-free flux methods (modern API). Once set, you can call getSolarFlux(),
149 getSolarFluxPAR(), etc. without passing atmospheric parameters.
150
151 Args:
152 pressure_Pa: Atmospheric pressure in Pascals (e.g., 101325 for sea level)
153 temperature_K: Temperature in Kelvin (e.g., 288.15 for 15°C)
154 humidity_rel: Relative humidity as fraction (0.0-1.0)
155 turbidity: Atmospheric turbidity coefficient (typically 0.02-0.5)
156
157 Raises:
158 ValueError: If atmospheric parameters are out of valid ranges
159 SolarPositionError: If operation fails
160
161 Note:
162 This is the modern API pattern. Atmospheric conditions are stored in Context
163 global data and reused by all parameter-free flux methods until changed.
164
165 Example:
166 >>> # Modern API (set once, use many times)
167 >>> with Context() as context:
168 ... with SolarPosition(context) as solar:
169 ... solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
170 ... flux = solar.getSolarFlux() # No parameters needed
171 ... par = solar.getSolarFluxPAR() # Uses same conditions
172 ... diffuse = solar.getDiffuseFraction() # Uses same conditions
173 """
174 # Validate parameters
175 if pressure_Pa < 0.0:
176 raise ValueError(f"Atmospheric pressure must be non-negative, got: {pressure_Pa}")
177 if temperature_K < 0.0:
178 raise ValueError(f"Temperature must be non-negative, got: {temperature_K}")
179 if humidity_rel < 0.0 or humidity_rel > 1.0:
180 raise ValueError(f"Relative humidity must be between 0 and 1, got: {humidity_rel}")
181 if turbidity < 0.0:
182 raise ValueError(f"Turbidity must be non-negative, got: {turbidity}")
183
184 try:
185 solar_wrapper.setAtmosphericConditions(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
186 except Exception as e:
187 raise SolarPositionError(f"Failed to set atmospheric conditions: {e}")
188
189 def getAtmosphericConditions(self) -> Tuple[float, float, float, float]:
190 """
191 Get currently set atmospheric conditions from Context.
192
193 Returns:
194 Tuple of (pressure_Pa, temperature_K, humidity_rel, turbidity)
195
196 Raises:
197 SolarPositionError: If operation fails
198
199 Note:
200 If atmospheric conditions have not been set via setAtmosphericConditions(),
201 returns default values: (101325 Pa, 300 K, 0.5, 0.02)
202
203 Example:
204 >>> pressure, temp, humidity, turbidity = solar.getAtmosphericConditions()
205 >>> print(f"Pressure: {pressure} Pa, Temp: {temp} K")
206 """
207 try:
208 return solar_wrapper.getAtmosphericConditions(self._solar_pos)
209 except Exception as e:
210 raise SolarPositionError(f"Failed to get atmospheric conditions: {e}")
211
212 # Solar angle calculations
213 def getSunElevation(self) -> float:
214 """
215 Get the sun elevation angle in degrees.
216
217 Returns:
218 Sun elevation angle in degrees (0° = horizon, 90° = zenith)
219
220 Raises:
221 SolarPositionError: If calculation fails
222
223 Example:
224 >>> elevation = solar.getSunElevation()
225 >>> print(f"Sun is {elevation:.1f}° above horizon")
226 """
227 try:
228 return solar_wrapper.getSunElevation(self._solar_pos)
229 except Exception as e:
230 raise SolarPositionError(f"Failed to get sun elevation: {e}")
231
232 def getSunZenith(self) -> float:
233 """
234 Get the sun zenith angle in degrees.
235
236 Returns:
237 Sun zenith angle in degrees (0° = zenith, 90° = horizon)
238
239 Raises:
240 SolarPositionError: If calculation fails
241
242 Example:
243 >>> zenith = solar.getSunZenith()
244 >>> print(f"Sun zenith angle: {zenith:.1f}°")
245 """
246 try:
247 return solar_wrapper.getSunZenith(self._solar_pos)
248 except Exception as e:
249 raise SolarPositionError(f"Failed to get sun zenith: {e}")
250
251 def getSunAzimuth(self) -> float:
252 """
253 Get the sun azimuth angle in degrees.
254
255 Returns:
256 Sun azimuth angle in degrees (0° = North, 90° = East, 180° = South, 270° = West)
257
258 Raises:
259 SolarPositionError: If calculation fails
260
261 Example:
262 >>> azimuth = solar.getSunAzimuth()
263 >>> print(f"Sun azimuth: {azimuth:.1f}° (compass bearing)")
264 """
265 try:
266 return solar_wrapper.getSunAzimuth(self._solar_pos)
267 except Exception as e:
268 raise SolarPositionError(f"Failed to get sun azimuth: {e}")
269
270 # Solar direction vectors
271 def getSunDirectionVector(self) -> vec3:
272 """
273 Get the sun direction as a 3D unit vector.
274
275 Returns:
276 vec3 representing the sun direction vector (x, y, z)
277
278 Raises:
279 SolarPositionError: If calculation fails
280
281 Example:
282 >>> direction = solar.getSunDirectionVector()
283 >>> print(f"Sun direction vector: ({direction.x:.3f}, {direction.y:.3f}, {direction.z:.3f})")
284 """
285 try:
286 direction_list = solar_wrapper.getSunDirectionVector(self._solar_pos)
287 return vec3(direction_list[0], direction_list[1], direction_list[2])
288 except Exception as e:
289 raise SolarPositionError(f"Failed to get sun direction vector: {e}")
290
291 def getSunDirectionSpherical(self) -> SphericalCoord:
292 """
293 Get the sun direction as spherical coordinates.
294
295 Returns:
296 SphericalCoord with radius=1, elevation and azimuth in radians
297
298 Raises:
299 SolarPositionError: If calculation fails
300
301 Example:
302 >>> spherical = solar.getSunDirectionSpherical()
303 >>> print(f"Spherical: r={spherical.radius}, elev={spherical.elevation:.3f}, az={spherical.azimuth:.3f}")
304 """
305 try:
306 spherical_list = solar_wrapper.getSunDirectionSpherical(self._solar_pos)
307 return SphericalCoord(
308 radius=spherical_list[0],
309 elevation=spherical_list[1],
310 azimuth=spherical_list[2]
311 )
312 except Exception as e:
313 raise SolarPositionError(f"Failed to get sun direction spherical: {e}")
314
315 # Solar flux calculations
316 def getSolarFlux(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
317 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
318 """
319 Calculate total solar flux (supports legacy and modern APIs).
320
321 This method supports both legacy and modern APIs:
322 - **Legacy API**: Pass all 4 atmospheric parameters explicitly
323 - **Modern API**: Pass no parameters, uses atmospheric conditions from setAtmosphericConditions()
324
325 Args:
326 pressure_Pa: Atmospheric pressure in Pascals (e.g., 101325 for sea level) [optional]
327 temperature_K: Temperature in Kelvin (e.g., 288.15 for 15°C) [optional]
328 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
329 turbidity: Atmospheric turbidity coefficient (typically 0.02-0.5) [optional]
330
331 Returns:
332 Total solar flux in W/m²
333
334 Raises:
335 ValueError: If some parameters provided but not all, or if values are invalid
336 SolarPositionError: If calculation fails or atmospheric conditions not set (modern API)
337
338 Examples:
339 Legacy API (backward compatible):
340 >>> flux = solar.getSolarFlux(101325, 288.15, 0.6, 0.1)
341
342 Modern API (cleaner, reuses atmospheric state):
343 >>> solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
344 >>> flux = solar.getSolarFlux() # No parameters needed
345 """
346 # Determine which API pattern is being used
347 params_provided = [pressure_Pa is not None, temperature_K is not None,
348 humidity_rel is not None, turbidity is not None]
349
350 if all(params_provided):
351 # Legacy API: All parameters provided
352 try:
353 return solar_wrapper.getSolarFlux(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
354 except Exception as e:
355 raise SolarPositionError(f"Failed to calculate solar flux: {e}")
356
357 elif not any(params_provided):
358 # Modern API: No parameters, use atmospheric conditions from Context
359 try:
360 return solar_wrapper.getSolarFluxFromState(self._solar_pos)
361 except Exception as e:
362 raise SolarPositionError(
363 f"Failed to calculate solar flux from atmospheric state: {e}\n"
364 "Hint: Call setAtmosphericConditions() first to use parameter-free API, "
365 "or provide all 4 atmospheric parameters for legacy API."
366 )
367
368 else:
369 # Error: Partial parameters provided
370 raise ValueError(
371 "Either provide all atmospheric parameters (pressure_Pa, temperature_K, humidity_rel, turbidity) "
372 "or provide none to use atmospheric conditions from setAtmosphericConditions(). "
373 "Partial parameter sets are not supported."
374 )
375
376 def getSolarFluxPAR(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
377 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
378 """
379 Calculate PAR (Photosynthetically Active Radiation) solar flux.
380
381 Supports both legacy (parameter-based) and modern (state-based) APIs.
382
383 Args:
384 pressure_Pa: Atmospheric pressure in Pascals [optional]
385 temperature_K: Temperature in Kelvin [optional]
386 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
387 turbidity: Atmospheric turbidity coefficient [optional]
388
389 Returns:
390 PAR solar flux in W/m² (wavelength range ~400-700 nm)
391
392 Raises:
393 ValueError: If some parameters provided but not all
394 SolarPositionError: If calculation fails
395
396 Examples:
397 Legacy: par_flux = solar.getSolarFluxPAR(101325, 288.15, 0.6, 0.1)
398 Modern: solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
399 par_flux = solar.getSolarFluxPAR()
400 """
401 params_provided = [pressure_Pa is not None, temperature_K is not None,
402 humidity_rel is not None, turbidity is not None]
403
404 if all(params_provided):
405 try:
406 return solar_wrapper.getSolarFluxPAR(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
407 except Exception as e:
408 raise SolarPositionError(f"Failed to calculate PAR flux: {e}")
409 elif not any(params_provided):
410 try:
411 return solar_wrapper.getSolarFluxPARFromState(self._solar_pos)
412 except Exception as e:
413 raise SolarPositionError(
414 f"Failed to calculate PAR flux from atmospheric state: {e}\n"
415 "Hint: Call setAtmosphericConditions() first."
416 )
417 else:
418 raise ValueError("Provide all atmospheric parameters or none (use setAtmosphericConditions()).")
419
420 def getSolarFluxNIR(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
421 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
422 """
423 Calculate NIR (Near-Infrared) solar flux.
424
425 Supports both legacy (parameter-based) and modern (state-based) APIs.
426
427 Args:
428 pressure_Pa: Atmospheric pressure in Pascals [optional]
429 temperature_K: Temperature in Kelvin [optional]
430 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
431 turbidity: Atmospheric turbidity coefficient [optional]
432
433 Returns:
434 NIR solar flux in W/m² (wavelength range >700 nm)
435
436 Raises:
437 ValueError: If some parameters provided but not all
438 SolarPositionError: If calculation fails
439
440 Examples:
441 Legacy: nir_flux = solar.getSolarFluxNIR(101325, 288.15, 0.6, 0.1)
442 Modern: solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
443 nir_flux = solar.getSolarFluxNIR()
444 """
445 params_provided = [pressure_Pa is not None, temperature_K is not None,
446 humidity_rel is not None, turbidity is not None]
447
448 if all(params_provided):
449 try:
450 return solar_wrapper.getSolarFluxNIR(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
451 except Exception as e:
452 raise SolarPositionError(f"Failed to calculate NIR flux: {e}")
453 elif not any(params_provided):
454 try:
455 return solar_wrapper.getSolarFluxNIRFromState(self._solar_pos)
456 except Exception as e:
457 raise SolarPositionError(
458 f"Failed to calculate NIR flux from atmospheric state: {e}\n"
459 "Hint: Call setAtmosphericConditions() first."
460 )
461 else:
462 raise ValueError("Provide all atmospheric parameters or none (use setAtmosphericConditions()).")
463
464 def getDiffuseFraction(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
465 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
466 """
467 Calculate the diffuse fraction of solar radiation.
468
469 Supports both legacy (parameter-based) and modern (state-based) APIs.
470
471 Args:
472 pressure_Pa: Atmospheric pressure in Pascals [optional]
473 temperature_K: Temperature in Kelvin [optional]
474 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
475 turbidity: Atmospheric turbidity coefficient [optional]
476
477 Returns:
478 Diffuse fraction as ratio (0.0-1.0) where:
479 - 0.0 = all direct radiation
480 - 1.0 = all diffuse radiation
481
482 Raises:
483 ValueError: If some parameters provided but not all
484 SolarPositionError: If calculation fails
485
486 Examples:
487 Legacy: diffuse = solar.getDiffuseFraction(101325, 288.15, 0.6, 0.1)
488 Modern: solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
489 diffuse = solar.getDiffuseFraction()
490 """
491 params_provided = [pressure_Pa is not None, temperature_K is not None,
492 humidity_rel is not None, turbidity is not None]
493
494 if all(params_provided):
495 try:
496 return solar_wrapper.getDiffuseFraction(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
497 except Exception as e:
498 raise SolarPositionError(f"Failed to calculate diffuse fraction: {e}")
499 elif not any(params_provided):
500 try:
501 return solar_wrapper.getDiffuseFractionFromState(self._solar_pos)
502 except Exception as e:
503 raise SolarPositionError(
504 f"Failed to calculate diffuse fraction from atmospheric state: {e}\n"
505 "Hint: Call setAtmosphericConditions() first."
506 )
507 else:
508 raise ValueError("Provide all atmospheric parameters or none (use setAtmosphericConditions()).")
509
510 def getAmbientLongwaveFlux(self, temperature_K: Optional[float] = None,
511 humidity_rel: Optional[float] = None) -> float:
512 """
513 Calculate the ambient (sky) longwave radiation flux.
514
515 This method supports both legacy and modern APIs:
516 - **Legacy API**: Pass temperature and humidity explicitly
517 - **Modern API**: Pass no parameters, uses atmospheric conditions from setAtmosphericConditions()
518
519 Args:
520 temperature_K: Temperature in Kelvin [optional]
521 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
522
523 Returns:
524 Ambient longwave flux in W/m²
525
526 Raises:
527 ValueError: If one parameter provided but not the other
528 SolarPositionError: If calculation fails
529
530 Note:
531 The longwave flux model is based on Prata (1996).
532 Returns downwelling longwave radiation flux on a horizontal surface.
533
534 Examples:
535 Legacy API:
536 >>> lw_flux = solar.getAmbientLongwaveFlux(288.15, 0.6)
537
538 Modern API (uses temperature and humidity from setAtmosphericConditions):
539 >>> solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
540 >>> lw_flux = solar.getAmbientLongwaveFlux()
541 """
542 params_provided = [temperature_K is not None, humidity_rel is not None]
543
544 if all(params_provided):
545 # Legacy API: Both parameters provided
546 # C++ has deprecated 2-parameter version, but we emulate it
547 # by setting atmospheric conditions temporarily
548 try:
549 # Get current conditions to restore later
550 saved_conditions = solar_wrapper.getAtmosphericConditions(self._solar_pos)
551
552 # Set temporary conditions with provided temperature and humidity
553 # Use current values for pressure and turbidity
554 solar_wrapper.setAtmosphericConditions(self._solar_pos,
555 saved_conditions[0], # pressure (unchanged)
556 temperature_K, # temperature (provided)
557 humidity_rel, # humidity (provided)
558 saved_conditions[3]) # turbidity (unchanged)
559
560 # Call parameter-free version
561 result = solar_wrapper.getAmbientLongwaveFluxFromState(self._solar_pos)
562
563 # Restore original conditions
564 solar_wrapper.setAtmosphericConditions(self._solar_pos, *saved_conditions)
565
566 return result
567
568 except Exception as e:
569 raise SolarPositionError(f"Failed to calculate ambient longwave flux: {e}")
570
571 elif not any(params_provided):
572 # Modern API: No parameters, use atmospheric conditions from Context
573 try:
574 return solar_wrapper.getAmbientLongwaveFluxFromState(self._solar_pos)
575 except Exception as e:
576 raise SolarPositionError(
577 f"Failed to calculate ambient longwave flux from atmospheric state: {e}\n"
578 "Hint: Call setAtmosphericConditions() first to use parameter-free API, "
579 "or provide temperature_K and humidity_rel for legacy API."
580 )
581
582 else:
583 # Error: Only one parameter provided
584 raise ValueError(
585 "Either provide both temperature_K and humidity_rel, "
586 "or provide neither to use atmospheric conditions from setAtmosphericConditions()."
587 )
588
589 # Time calculations
590 def getSunriseTime(self) -> Time:
591 """
592 Calculate sunrise time for the current date and location.
593
594 Returns:
595 Time object with sunrise time (hour, minute, second)
596
597 Raises:
598 SolarPositionError: If calculation fails
599
600 Example:
601 >>> sunrise = solar.getSunriseTime()
602 >>> print(f"Sunrise: {sunrise}") # Prints as HH:MM:SS
603 """
604 try:
605 hour, minute, second = solar_wrapper.getSunriseTime(self._solar_pos)
606 return Time(hour, minute, second)
607 except Exception as e:
608 raise SolarPositionError(f"Failed to calculate sunrise time: {e}")
609
610 def getSunsetTime(self) -> Time:
611 """
612 Calculate sunset time for the current date and location.
613
614 Returns:
615 Time object with sunset time (hour, minute, second)
616
617 Raises:
618 SolarPositionError: If calculation fails
619
620 Example:
621 >>> sunset = solar.getSunsetTime()
622 >>> print(f"Sunset: {sunset}") # Prints as HH:MM:SS
623 """
624 try:
625 hour, minute, second = solar_wrapper.getSunsetTime(self._solar_pos)
626 return Time(hour, minute, second)
627 except Exception as e:
628 raise SolarPositionError(f"Failed to calculate sunset time: {e}")
629
630 # Calibration functions
631 def calibrateTurbidityFromTimeseries(self, timeseries_label: str):
632 """
633 Calibrate atmospheric turbidity using timeseries data.
634
635 Args:
636 timeseries_label: Label of timeseries data in Context
637
638 Raises:
639 ValueError: If timeseries label is invalid
640 SolarPositionError: If calibration fails
641
642 Example:
643 >>> solar.calibrateTurbidityFromTimeseries("solar_irradiance")
644 """
645 if not timeseries_label:
646 raise ValueError("Timeseries label cannot be empty")
647
648 try:
649 solar_wrapper.calibrateTurbidityFromTimeseries(self._solar_pos, timeseries_label)
650 except Exception as e:
651 raise SolarPositionError(f"Failed to calibrate turbidity: {e}")
652
653 def enableCloudCalibration(self, timeseries_label: str):
654 """
655 Enable cloud calibration using timeseries data.
656
657 Args:
658 timeseries_label: Label of cloud timeseries data in Context
659
660 Raises:
661 ValueError: If timeseries label is invalid
662 SolarPositionError: If calibration setup fails
663
664 Example:
665 >>> solar.enableCloudCalibration("cloud_cover")
666 """
667 if not timeseries_label:
668 raise ValueError("Timeseries label cannot be empty")
669
670 try:
671 solar_wrapper.enableCloudCalibration(self._solar_pos, timeseries_label)
672 except Exception as e:
673 raise SolarPositionError(f"Failed to enable cloud calibration: {e}")
674
675 def disableCloudCalibration(self):
676 """
677 Disable cloud calibration.
678
679 Raises:
680 SolarPositionError: If operation fails
681
682 Example:
683 >>> solar.disableCloudCalibration()
684 """
685 try:
686 solar_wrapper.disableCloudCalibration(self._solar_pos)
687 except Exception as e:
688 raise SolarPositionError(f"Failed to disable cloud calibration: {e}")
689
690 # Prague Sky Model Methods (v1.3.59+)
691 def enablePragueSkyModel(self):
692 """
693 Enable Prague Sky Model for physically-based sky radiance calculations.
694
695 The Prague Sky Model provides high-quality spectral and angular sky radiance
696 distribution for accurate diffuse radiation modeling. It accounts for Rayleigh
697 and Mie scattering to produce realistic sky radiance patterns across the
698 360-1480 nm spectral range.
699
700 Raises:
701 SolarPositionError: If operation fails
702
703 Note:
704 After enabling, call updatePragueSkyModel() to compute and store spectral-angular
705 parameters in Context global data. Requires ~27 MB data file:
706 plugins/solarposition/lib/prague_sky_model/PragueSkyModelReduced.dat
707
708 Example:
709 >>> with Context() as context:
710 ... with SolarPosition(context) as solar:
711 ... solar.enablePragueSkyModel()
712 ... solar.updatePragueSkyModel()
713 """
714 try:
715 solar_wrapper.enablePragueSkyModel(self._solar_pos)
716 except Exception as e:
717 raise SolarPositionError(f"Failed to enable Prague Sky Model: {e}")
718
719 def isPragueSkyModelEnabled(self) -> bool:
720 """
721 Check if Prague Sky Model is currently enabled.
722
723 Returns:
724 True if Prague Sky Model has been enabled via enablePragueSkyModel(), False otherwise
725
726 Raises:
727 SolarPositionError: If operation fails
728
729 Example:
730 >>> if solar.isPragueSkyModelEnabled():
731 ... print("Prague Sky Model is active")
732 """
733 try:
734 return solar_wrapper.isPragueSkyModelEnabled(self._solar_pos)
735 except Exception as e:
736 raise SolarPositionError(f"Failed to check Prague Sky Model status: {e}")
737
738 def updatePragueSkyModel(self, ground_albedo: float = 0.33):
739 """
740 Update Prague Sky Model and store spectral-angular parameters in Context.
741
742 This is a computationally intensive operation (~1100 model queries with OpenMP
743 parallelization) that computes sky radiance distribution for current atmospheric
744 and solar conditions. Use pragueSkyModelNeedsUpdate() for lazy evaluation to
745 avoid unnecessary updates.
746
747 Args:
748 ground_albedo: Ground surface albedo (default: 0.33 for typical soil/vegetation)
749
750 Raises:
751 SolarPositionError: If update fails
752
753 Note:
754 Reads turbidity from Context atmospheric conditions. Stores results in Context
755 global data as "prague_sky_spectral_params" (1350 floats: 225 wavelengths × 6 params),
756 "prague_sky_sun_direction", "prague_sky_visibility_km", "prague_sky_ground_albedo",
757 and "prague_sky_valid" flag.
758
759 Example:
760 >>> solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
761 >>> solar.updatePragueSkyModel(ground_albedo=0.25)
762 """
763 try:
764 solar_wrapper.updatePragueSkyModel(self._solar_pos, ground_albedo)
765 except Exception as e:
766 raise SolarPositionError(f"Failed to update Prague Sky Model: {e}")
767
768 def pragueSkyModelNeedsUpdate(self, ground_albedo: float = 0.33,
769 sun_tolerance: float = 0.01,
770 turbidity_tolerance: float = 0.02,
771 albedo_tolerance: float = 0.05) -> bool:
772 """
773 Check if Prague Sky Model needs updating based on changed conditions.
774
775 Enables lazy evaluation to avoid expensive Prague updates when conditions haven't
776 changed significantly. Compares current state against cached values.
777
778 Args:
779 ground_albedo: Current ground albedo (default: 0.33)
780 sun_tolerance: Threshold for sun direction changes (default: 0.01 ≈ 0.57°)
781 turbidity_tolerance: Relative threshold for turbidity (default: 0.02 = 2%)
782 albedo_tolerance: Threshold for albedo changes (default: 0.05 = 5%)
783
784 Returns:
785 True if updatePragueSkyModel() should be called, False if cached data is valid
786
787 Raises:
788 SolarPositionError: If check fails
789
790 Note:
791 Reads turbidity from Context atmospheric conditions for comparison.
792
793 Example:
794 >>> if solar.pragueSkyModelNeedsUpdate():
795 ... solar.updatePragueSkyModel()
796 """
797 try:
798 return solar_wrapper.pragueSkyModelNeedsUpdate(self._solar_pos, ground_albedo,
799 sun_tolerance, turbidity_tolerance,
800 albedo_tolerance)
801 except Exception as e:
802 raise SolarPositionError(f"Failed to check Prague Sky Model update status: {e}")
803
804 # SSolar-GOA Spectral Solar Model Methods
805 def calculateDirectSolarSpectrum(self, label: str, resolution_nm: float = 1.0):
806 """
807 Calculate direct beam solar spectrum using SSolar-GOA model.
808
809 Computes the spectral irradiance of direct beam solar radiation across
810 300-2600 nm wavelength range using the SSolar-GOA (Global Ozone and
811 Atmospheric) spectral model. Results are stored in Context global data
812 as a vector of (wavelength, irradiance) pairs.
813
814 Args:
815 label: Label to store the spectrum data in Context global data
816 resolution_nm: Wavelength resolution in nanometers (1.0-2300.0).
817 Lower values give finer spectral resolution but require
818 more computation. Default is 1.0 nm.
819
820 Raises:
821 ValueError: If label is empty or resolution is out of valid range
822 SolarPositionError: If calculation fails
823
824 Note:
825 - Requires Context time/date to be set for accurate solar position
826 - Atmospheric parameters from Context location are used
827 - Results accessible via context.getGlobalData(label)
828 - SSolar-GOA model accounts for atmospheric absorption and scattering
829
830 Example:
831 >>> with Context() as context:
832 ... context.setDate(2023, 6, 21)
833 ... context.setTime(12, 0)
834 ... with SolarPosition(context) as solar:
835 ... solar.calculateDirectSolarSpectrum("direct_spectrum", resolution_nm=5.0)
836 ... spectrum = context.getGlobalData("direct_spectrum")
837 ... # spectrum is list of vec2(wavelength_nm, irradiance_W_m2_nm)
838 """
839 if not label:
840 raise ValueError("Label cannot be empty")
841 if resolution_nm < 1.0 or resolution_nm > 2300.0:
842 raise ValueError(f"Wavelength resolution must be between 1 and 2300 nm, got: {resolution_nm}")
843
844 try:
845 solar_wrapper.calculateDirectSolarSpectrum(self._solar_pos, label, resolution_nm)
846 except Exception as e:
847 raise SolarPositionError(f"Failed to calculate direct solar spectrum: {e}")
848
849 def calculateDiffuseSolarSpectrum(self, label: str, resolution_nm: float = 1.0):
850 """
851 Calculate diffuse solar spectrum using SSolar-GOA model.
852
853 Computes the spectral irradiance of diffuse (scattered) solar radiation
854 across 300-2600 nm wavelength range using the SSolar-GOA model. Results
855 are stored in Context global data as a vector of (wavelength, irradiance) pairs.
856
857 Args:
858 label: Label to store the spectrum data in Context global data
859 resolution_nm: Wavelength resolution in nanometers (1.0-2300.0).
860 Lower values give finer spectral resolution but require
861 more computation. Default is 1.0 nm.
862
863 Raises:
864 ValueError: If label is empty or resolution is out of valid range
865 SolarPositionError: If calculation fails
866
867 Note:
868 - Requires Context time/date to be set for accurate solar position
869 - Atmospheric parameters from Context location are used
870 - Results accessible via context.getGlobalData(label)
871 - Diffuse radiation results from atmospheric scattering (Rayleigh, aerosol)
872
873 Example:
874 >>> with Context() as context:
875 ... context.setDate(2023, 6, 21)
876 ... context.setTime(12, 0)
877 ... with SolarPosition(context) as solar:
878 ... solar.calculateDiffuseSolarSpectrum("diffuse_spectrum", resolution_nm=5.0)
879 ... spectrum = context.getGlobalData("diffuse_spectrum")
880 ... # spectrum is list of vec2(wavelength_nm, irradiance_W_m2_nm)
881 """
882 if not label:
883 raise ValueError("Label cannot be empty")
884 if resolution_nm < 1.0 or resolution_nm > 2300.0:
885 raise ValueError(f"Wavelength resolution must be between 1 and 2300 nm, got: {resolution_nm}")
886
887 try:
888 solar_wrapper.calculateDiffuseSolarSpectrum(self._solar_pos, label, resolution_nm)
889 except Exception as e:
890 raise SolarPositionError(f"Failed to calculate diffuse solar spectrum: {e}")
891
892 def calculateGlobalSolarSpectrum(self, label: str, resolution_nm: float = 1.0):
893 """
894 Calculate global (total) solar spectrum using SSolar-GOA model.
895
896 Computes the spectral irradiance of total solar radiation (direct + diffuse)
897 across 300-2600 nm wavelength range using the SSolar-GOA model. Results
898 are stored in Context global data as a vector of (wavelength, irradiance) pairs.
899
900 Args:
901 label: Label to store the spectrum data in Context global data
902 resolution_nm: Wavelength resolution in nanometers (1.0-2300.0).
903 Lower values give finer spectral resolution but require
904 more computation. Default is 1.0 nm.
905
906 Raises:
907 ValueError: If label is empty or resolution is out of valid range
908 SolarPositionError: If calculation fails
909
910 Note:
911 - Requires Context time/date to be set for accurate solar position
912 - Atmospheric parameters from Context location are used
913 - Results accessible via context.getGlobalData(label)
914 - Global spectrum = direct beam + diffuse (sky) radiation
915 - Most useful for plant canopy modeling and photosynthesis calculations
916
917 Example:
918 >>> with Context() as context:
919 ... context.setDate(2023, 6, 21)
920 ... context.setTime(12, 0)
921 ... with SolarPosition(context) as solar:
922 ... solar.calculateGlobalSolarSpectrum("global_spectrum", resolution_nm=10.0)
923 ... spectrum = context.getGlobalData("global_spectrum")
924 ... # spectrum is list of vec2(wavelength_nm, irradiance_W_m2_nm)
925 ... total_irradiance = sum([s.y for s in spectrum]) * 10.0 # Integrate
926 """
927 if not label:
928 raise ValueError("Label cannot be empty")
929 if resolution_nm < 1.0 or resolution_nm > 2300.0:
930 raise ValueError(f"Wavelength resolution must be between 1 and 2300 nm, got: {resolution_nm}")
931
932 try:
933 solar_wrapper.calculateGlobalSolarSpectrum(self._solar_pos, label, resolution_nm)
934 except Exception as e:
935 raise SolarPositionError(f"Failed to calculate global solar spectrum: {e}")
936
937 def is_available(self) -> bool:
938 """
939 Check if SolarPosition is available in current build.
940
941 Returns:
942 True if plugin is available, False otherwise
943 """
944 registry = get_plugin_registry()
945 return registry.is_plugin_available('solarposition')
946
947
948# Convenience function
949def create_solar_position(context: Context, utc_offset: Optional[float] = None,
950 latitude: Optional[float] = None, longitude: Optional[float] = None) -> SolarPosition:
951 """
952 Create SolarPosition instance with context and optional coordinates.
953
954 Args:
955 context: Helios Context
956 utc_offset: UTC time offset in hours (optional)
957 latitude: Latitude in degrees (optional)
958 longitude: Longitude in degrees (optional)
959
960 Returns:
961 SolarPosition instance
962
963 Example:
964 >>> solar = create_solar_position(context, utc_offset=-8, latitude=38.5, longitude=-121.7)
965 """
966 return SolarPosition(context, utc_offset, latitude, longitude)
Exception raised for SolarPosition-specific errors.
High-level interface for solar position calculations and radiation modeling.
bool pragueSkyModelNeedsUpdate(self, float ground_albedo=0.33, float sun_tolerance=0.01, float turbidity_tolerance=0.02, float albedo_tolerance=0.05)
Check if Prague Sky Model needs updating based on changed conditions.
__init__(self, Context context, Optional[float] utc_offset=None, Optional[float] latitude=None, Optional[float] longitude=None)
Initialize SolarPosition with a Helios context.
enableCloudCalibration(self, str timeseries_label)
Enable cloud calibration using timeseries data.
Time getSunriseTime(self)
Calculate sunrise time for the current date and location.
float getSunZenith(self)
Get the sun zenith angle in degrees.
float getSunAzimuth(self)
Get the sun azimuth angle in degrees.
float getAmbientLongwaveFlux(self, Optional[float] temperature_K=None, Optional[float] humidity_rel=None)
Calculate the ambient (sky) longwave radiation flux.
enablePragueSkyModel(self)
Enable Prague Sky Model for physically-based sky radiance calculations.
float getSolarFluxPAR(self, Optional[float] pressure_Pa=None, Optional[float] temperature_K=None, Optional[float] humidity_rel=None, Optional[float] turbidity=None)
Calculate PAR (Photosynthetically Active Radiation) solar flux.
None setAtmosphericConditions(self, float pressure_Pa, float temperature_K, float humidity_rel, float turbidity)
Set atmospheric conditions for subsequent flux calculations (modern API).
float getSolarFlux(self, Optional[float] pressure_Pa=None, Optional[float] temperature_K=None, Optional[float] humidity_rel=None, Optional[float] turbidity=None)
Calculate total solar flux (supports legacy and modern APIs).
bool isPragueSkyModelEnabled(self)
Check if Prague Sky Model is currently enabled.
float getSunElevation(self)
Get the sun elevation angle in degrees.
bool is_available(self)
Check if SolarPosition is available in current build.
calculateDirectSolarSpectrum(self, str label, float resolution_nm=1.0)
Calculate direct beam solar spectrum using SSolar-GOA model.
calculateGlobalSolarSpectrum(self, str label, float resolution_nm=1.0)
Calculate global (total) solar spectrum using SSolar-GOA model.
updatePragueSkyModel(self, float ground_albedo=0.33)
Update Prague Sky Model and store spectral-angular parameters in Context.
SphericalCoord getSunDirectionSpherical(self)
Get the sun direction as spherical coordinates.
Time getSunsetTime(self)
Calculate sunset time for the current date and location.
__enter__(self)
Context manager entry.
__exit__(self, exc_type, exc_val, exc_tb)
Context manager exit - cleanup resources.
Tuple[float, float, float, float] getAtmosphericConditions(self)
Get currently set atmospheric conditions from Context.
calibrateTurbidityFromTimeseries(self, str timeseries_label)
Calibrate atmospheric turbidity using timeseries data.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
float getSolarFluxNIR(self, Optional[float] pressure_Pa=None, Optional[float] temperature_K=None, Optional[float] humidity_rel=None, Optional[float] turbidity=None)
Calculate NIR (Near-Infrared) solar flux.
calculateDiffuseSolarSpectrum(self, str label, float resolution_nm=1.0)
Calculate diffuse solar spectrum using SSolar-GOA model.
disableCloudCalibration(self)
Disable cloud calibration.
float getDiffuseFraction(self, Optional[float] pressure_Pa=None, Optional[float] temperature_K=None, Optional[float] humidity_rel=None, Optional[float] turbidity=None)
Calculate the diffuse fraction of solar radiation.
vec3 getSunDirectionVector(self)
Get the sun direction as a 3D unit vector.
Exception classes for PyHelios library.
Definition exceptions.py:10
Helios Time structure for representing time values.
Definition DataTypes.py:672
SolarPosition create_solar_position(Context context, Optional[float] utc_offset=None, Optional[float] latitude=None, Optional[float] longitude=None)
Create SolarPosition instance with context and optional coordinates.