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