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