0.1.39
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# Runtime data files that the SolarPosition C++ code opens by relative path, given relative to the
25# plugin's asset directory ("plugins/solarposition" under the build directory).
26_PRAGUE_DATASET = 'lib/prague_sky_model/PragueSkyModelReduced.dat'
27_OZONE_CLIMATOLOGY = 'ozone_climatology/sbuv_total_ozone_climatology.txt'
28_EXTRATERRESTRIAL_SPECTRUM = 'ssolar_goa/wehrli85.txt'
29_ATMOSPHERE_LUT = 'atmosphere_lut/atmosphere_lut.bin'
30_THERMAL_ATMOSPHERE_LUT = 'thermal_atmosphere_lut/thermal_atmosphere_lut.bin'
31
32# Files read by the spectral solar irradiance and sensor atmosphere models
33_SPECTRAL_MODEL_ASSETS = (_OZONE_CLIMATOLOGY, _EXTRATERRESTRIAL_SPECTRUM, _ATMOSPHERE_LUT)
34# Files read by the thermal sensor atmosphere model
35_THERMAL_MODEL_ASSETS = (_OZONE_CLIMATOLOGY, _THERMAL_ATMOSPHERE_LUT)
36
38@contextmanager
39def _solarposition_working_directory(required_assets: Tuple[str, ...] = (_PRAGUE_DATASET,)):
40 """
41 Context manager that temporarily changes working directory to where SolarPosition assets are located.
42
43 SolarPosition C++ code opens its data files (the Prague sky model dataset, the ozone climatology,
44 the extraterrestrial solar spectrum and the atmospheric look-up tables) through hardcoded relative
45 paths such as "plugins/solarposition/lib/prague_sky_model/PragueSkyModelReduced.dat", so they are
46 only resolvable when the process is running from the build directory. This manager temporarily
47 changes to the build directory where the files actually live.
48
49 Args:
50 required_assets: Paths, relative to the "plugins/solarposition" asset directory, of the data
51 files the enclosed call reads.
52
53 Raises:
54 RuntimeError: If the build directory or a required data file is not found, indicating a
55 build system error.
56 """
57 asset_manager = get_asset_manager()
58 working_dir = asset_manager._get_helios_build_path()
59
60 if working_dir and working_dir.exists():
61 solarposition_assets = working_dir / 'plugins' / 'solarposition'
62 else:
63 # For wheel installations, check packaged assets
64 current_dir = Path(__file__).parent
65 packaged_build = current_dir / 'assets' / 'build'
66
67 if packaged_build.exists():
68 working_dir = packaged_build
69 solarposition_assets = working_dir / 'plugins' / 'solarposition'
70 else:
71 # Fallback to development paths
72 repo_root = current_dir.parent
73 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
74 working_dir = build_lib_dir.parent
75 solarposition_assets = working_dir / 'plugins' / 'solarposition'
76
77 if not build_lib_dir.exists():
78 raise RuntimeError(
79 f"PyHelios build directory not found at {build_lib_dir}. "
80 f"Run: build_scripts/build_helios --clean"
81 )
82
83 for asset in required_assets:
84 asset_path = solarposition_assets / asset
85 if not asset_path.exists():
86 if asset == _PRAGUE_DATASET:
87 raise RuntimeError(
88 f"Prague sky model dataset not found at {asset_path}. "
89 f"This indicates a build system error. The build script should copy the "
90 f"~26 MB dataset to this location. "
91 f"Rebuild with: build_scripts/build_helios --clean"
92 )
93 raise RuntimeError(
94 f"SolarPosition data file not found at {asset_path}. "
95 f"This indicates a build system error. The build script should copy the "
96 f"solarposition plugin assets to this location. "
97 f"Rebuild with: build_scripts/build_helios --clean"
98 )
99
100 original_dir = os.getcwd()
101 try:
102 os.chdir(working_dir)
103 logger.debug(f"Changed working directory to {working_dir} for SolarPosition asset access")
104 yield working_dir
105 finally:
106 os.chdir(original_dir)
107 logger.debug(f"Restored working directory to {original_dir}")
108
109
110def _validate_sensor_arguments(label, direction_to_sensor) -> None:
111 """Validate the label and sensor direction shared by the sensor atmosphere methods."""
112 if not isinstance(label, str):
113 raise ValueError(f"Label must be a string, got {type(label).__name__}")
114 if not label:
115 raise ValueError("Label cannot be empty")
116 if not isinstance(direction_to_sensor, vec3):
117 raise ValueError(f"direction_to_sensor must be a vec3, got {type(direction_to_sensor).__name__}")
118 if direction_to_sensor.x == 0.0 and direction_to_sensor.y == 0.0 and direction_to_sensor.z == 0.0:
119 raise ValueError("direction_to_sensor must be a non-zero vector")
120
121
123 """Exception raised for SolarPosition-specific errors"""
124 pass
125
126
127class SolarPosition:
128 """
129 High-level interface for solar position calculations and radiation modeling.
130
131 SolarPosition provides comprehensive solar angle calculations, radiation flux
132 modeling, sunrise/sunset time calculations, and atmospheric turbidity calibration.
133 The plugin automatically uses Context time/date for calculations or can be
134 initialized with explicit coordinates.
135
136 This class requires the native Helios library built with SolarPosition support.
137 Use context managers for proper resource cleanup.
138
139 Examples:
140 Basic usage with Context coordinates:
141 >>> with Context() as context:
142 ... context.setDate(2023, 6, 21) # Summer solstice
143 ... context.setTime(12, 0) # Solar noon
144 ... with SolarPosition(context) as solar:
145 ... elevation = solar.getSunElevation()
146 ... print(f"Sun elevation: {elevation:.1f}°")
147
148 Usage with explicit coordinates:
149 >>> with Context() as context:
150 ... # Davis, California coordinates
151 ... with SolarPosition(context, utc_offset=-8, latitude=38.5, longitude=-121.7) as solar:
152 ... azimuth = solar.getSunAzimuth()
153 ... flux = solar.getSolarFlux(101325, 288.15, 0.6, 0.1)
154 ... print(f"Solar flux: {flux:.1f} W/m²")
155 """
156
157 def __init__(self, context: Context, utc_offset: Optional[float] = None,
158 latitude: Optional[float] = None, longitude: Optional[float] = None):
159 """
160 Initialize SolarPosition with a Helios context.
161
162 Args:
163 context: Active Helios Context instance
164 utc_offset: UTC time offset in hours (-14 to +12). Helios counts the offset
165 positive moving West, which inverts the real-world UTC-12..UTC+14
166 span, so the range is asymmetric. If provided with
167 latitude/longitude, creates plugin with explicit coordinates.
168 latitude: Latitude in degrees (-90 to +90). Required if utc_offset provided.
169 longitude: Longitude in degrees (-180 to +180). Required if utc_offset provided.
170
171 Raises:
172 SolarPositionError: If plugin not available in current build
173 ValueError: If coordinate parameters are invalid or incomplete
174 RuntimeError: If plugin initialization fails
175
176 Note:
177 If coordinates are not provided, the plugin uses Context location settings.
178 Solar calculations depend on Context time/date - use context.setTime() and
179 context.setDate() to set the simulation time before calculations.
180 """
181 # Check plugin availability
182 registry = get_plugin_registry()
183 if not registry.is_plugin_available('solarposition'):
184 raise SolarPositionError(
185 "SolarPosition not available in current Helios library. "
186 "SolarPosition plugin availability depends on build configuration.\n"
187 "\n"
188 "System requirements:\n"
189 " - Platforms: Windows, Linux, macOS\n"
190 " - Dependencies: None\n"
191 " - GPU: Not required\n"
192 "\n"
193 "If you're seeing this error, the SolarPosition plugin may not be "
194 "properly compiled into your Helios library. Please rebuild PyHelios:\n"
195 " build_scripts/build_helios --clean"
196 )
197
198 # Validate coordinate parameters
199 if utc_offset is not None or latitude is not None or longitude is not None:
200 # If any coordinate parameter is provided, all must be provided
201 if utc_offset is None or latitude is None or longitude is None:
202 raise ValueError(
203 "If specifying coordinates, all three parameters must be provided: "
204 "utc_offset, latitude, longitude"
205 )
206
207 # Validate coordinate ranges
208 # Range matches helios::Location::validate(): asymmetric because Helios
209 # counts the UTC offset positive moving West, inverting real-world
210 # UTC-12..UTC+14 to +12..-14.
211 if utc_offset < -14.0 or utc_offset > 12.0:
212 raise ValueError(f"UTC offset must be between -14 and +12 hours, got: {utc_offset}")
213 if latitude < -90.0 or latitude > 90.0:
214 raise ValueError(f"Latitude must be between -90 and +90 degrees, got: {latitude}")
215 if longitude < -180.0 or longitude > 180.0:
216 raise ValueError(f"Longitude must be between -180 and +180 degrees, got: {longitude}")
217
218 # Create with explicit coordinates
219 self.context = context
220 self._solar_pos = solar_wrapper.createSolarPositionWithCoordinates(
221 context.getNativePtr(), utc_offset, latitude, longitude
222 )
223 else:
224 # Create using Context location
225 self.context = context
226 self._solar_pos = solar_wrapper.createSolarPosition(context.getNativePtr())
227
228 if not self._solar_pos:
229 raise SolarPositionError("Failed to initialize SolarPosition")
230
231 def _check_context_alive(self):
232 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
233 check_context_alive(getattr(self, "context", None), "SolarPosition")
234
235 def __enter__(self):
236 """Context manager entry"""
237 return self
238
239 def __exit__(self, exc_type, exc_val, exc_tb):
240 """Context manager exit - cleanup resources"""
241 if hasattr(self, '_solar_pos') and self._solar_pos:
242 solar_wrapper.destroySolarPosition(self._solar_pos)
243 self._solar_pos = None
245 def __del__(self):
246 """Destructor to ensure C++ resources freed even without 'with' statement."""
247 if hasattr(self, '_solar_pos') and self._solar_pos is not None:
248 try:
249 solar_wrapper.destroySolarPosition(self._solar_pos)
250 self._solar_pos = None
251 except Exception as e:
252 import warnings
253 warnings.warn(f"Error in SolarPosition.__del__: {e}")
254
255 # Atmospheric condition management (modern API)
256 def setAtmosphericConditions(self, pressure_Pa: float, temperature_K: float,
257 humidity_rel: float, turbidity: float) -> None:
258 """
259 Set atmospheric conditions for subsequent flux calculations (modern API).
260
261 This method sets global atmospheric conditions in the Context that are used
262 by parameter-free flux methods (modern API). Once set, you can call getSolarFlux(),
263 getSolarFluxPAR(), etc. without passing atmospheric parameters.
264
265 Args:
266 pressure_Pa: Atmospheric pressure in Pascals (e.g., 101325 for sea level)
267 temperature_K: Temperature in Kelvin (e.g., 288.15 for 15°C)
268 humidity_rel: Relative humidity as fraction (0.0-1.0). The spectral solar irradiance,
269 sensor atmosphere and thermal atmosphere methods derive the precipitable water
270 from it and require a value greater than 0.
271 turbidity: Ångström's aerosol turbidity coefficient (β), the aerosol optical depth
272 at 1 µm (must be >= 0). The aerosol optical depth at 550 nm is about 2.18 β and
273 that at 500 nm about 2.46 β, so an aerosol optical depth measured at 500 nm
274 corresponds to β of about 0.41 times that value. This is not Linke turbidity.
275
276 Raises:
277 ValueError: If atmospheric parameters are out of valid ranges
278 SolarPositionError: If operation fails
279
280 Note:
281 This is the modern API pattern. Atmospheric conditions are stored in Context
282 global data and reused by all parameter-free flux methods until changed.
283
284 Example:
285 >>> # Modern API (set once, use many times)
286 >>> with Context() as context:
287 ... with SolarPosition(context) as solar:
288 ... solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
289 ... flux = solar.getSolarFlux() # No parameters needed
290 ... par = solar.getSolarFluxPAR() # Uses same conditions
291 ... diffuse = solar.getDiffuseFraction() # Uses same conditions
292 """
293 # Validate parameters
294 if pressure_Pa < 0.0:
295 raise ValueError(f"Atmospheric pressure must be non-negative, got: {pressure_Pa}")
296 if temperature_K < 0.0:
297 raise ValueError(f"Temperature must be non-negative, got: {temperature_K}")
298 if humidity_rel < 0.0 or humidity_rel > 1.0:
299 raise ValueError(f"Relative humidity must be between 0 and 1, got: {humidity_rel}")
300 if turbidity < 0.0:
301 raise ValueError(f"Turbidity must be non-negative, got: {turbidity}")
302
304 try:
305 solar_wrapper.setAtmosphericConditions(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
306 except Exception as e:
307 raise SolarPositionError(f"Failed to set atmospheric conditions: {e}")
308
309 def getAtmosphericConditions(self) -> Tuple[float, float, float, float]:
310 """
311 Get currently set atmospheric conditions from Context.
312
313 Returns:
314 Tuple of (pressure_Pa, temperature_K, humidity_rel, turbidity), where turbidity is
315 Ångström's aerosol turbidity coefficient (β), the aerosol optical depth at 1 µm
316
317 Raises:
318 SolarPositionError: If operation fails
319
320 Note:
321 If atmospheric conditions have not been set via setAtmosphericConditions(),
322 returns default values: (101325 Pa, 300 K, 0.5, 0.02)
323
324 Example:
325 >>> pressure, temp, humidity, turbidity = solar.getAtmosphericConditions()
326 >>> print(f"Pressure: {pressure} Pa, Temp: {temp} K")
327 """
329 try:
330 return solar_wrapper.getAtmosphericConditions(self._solar_pos)
331 except Exception as e:
332 raise SolarPositionError(f"Failed to get atmospheric conditions: {e}")
333
334 # Solar angle calculations
335 def getSunElevation(self) -> float:
336 """
337 Get the sun elevation angle in radians.
338
339 Returns:
340 Sun elevation angle in radians (0 = horizon, pi/2 = zenith)
341
342 Raises:
343 SolarPositionError: If calculation fails
344
345 Example:
346 >>> import math
347 >>> elevation = solar.getSunElevation()
348 >>> print(f"Sun is {math.degrees(elevation):.1f}° above horizon")
349 """
351 try:
352 return solar_wrapper.getSunElevation(self._solar_pos)
353 except Exception as e:
354 raise SolarPositionError(f"Failed to get sun elevation: {e}")
355
356 def getSunZenith(self) -> float:
357 """
358 Get the sun zenith angle in radians.
359
360 Returns:
361 Sun zenith angle in radians (0 = zenith, pi/2 = horizon)
362
363 Raises:
364 SolarPositionError: If calculation fails
365
366 Example:
367 >>> import math
368 >>> zenith = solar.getSunZenith()
369 >>> print(f"Sun zenith angle: {math.degrees(zenith):.1f}°")
370 """
372 try:
373 return solar_wrapper.getSunZenith(self._solar_pos)
374 except Exception as e:
375 raise SolarPositionError(f"Failed to get sun zenith: {e}")
377 def getSunAzimuth(self) -> float:
378 """
379 Get the sun azimuth angle in radians.
380
381 Returns:
382 Sun azimuth angle in radians (0 = North, pi/2 = East, pi = South,
383 3*pi/2 = West)
384
385 Raises:
386 SolarPositionError: If calculation fails
387
388 Example:
389 >>> import math
390 >>> azimuth = solar.getSunAzimuth()
391 >>> print(f"Sun azimuth: {math.degrees(azimuth):.1f}° (compass bearing)")
392 """
394 try:
395 return solar_wrapper.getSunAzimuth(self._solar_pos)
396 except Exception as e:
397 raise SolarPositionError(f"Failed to get sun azimuth: {e}")
398
399 # Solar direction vectors
400 def getSunDirectionVector(self) -> vec3:
401 """
402 Get the sun direction as a 3D unit vector.
403
404 Returns:
405 vec3 representing the sun direction vector (x, y, z)
406
407 Raises:
408 SolarPositionError: If calculation fails
409
410 Example:
411 >>> direction = solar.getSunDirectionVector()
412 >>> print(f"Sun direction vector: ({direction.x:.3f}, {direction.y:.3f}, {direction.z:.3f})")
413 """
415 try:
416 direction_list = solar_wrapper.getSunDirectionVector(self._solar_pos)
417 return vec3(direction_list[0], direction_list[1], direction_list[2])
418 except Exception as e:
419 raise SolarPositionError(f"Failed to get sun direction vector: {e}")
420
421 def getSunDirectionSpherical(self) -> SphericalCoord:
422 """
423 Get the sun direction as spherical coordinates.
424
425 Returns:
426 SphericalCoord with radius=1, elevation and azimuth in radians
427
428 Raises:
429 SolarPositionError: If calculation fails
430
431 Example:
432 >>> spherical = solar.getSunDirectionSpherical()
433 >>> print(f"Spherical: r={spherical.radius}, elev={spherical.elevation:.3f}, az={spherical.azimuth:.3f}")
434 """
436 try:
437 spherical_list = solar_wrapper.getSunDirectionSpherical(self._solar_pos)
438 return SphericalCoord(
439 radius=spherical_list[0],
440 elevation=spherical_list[1],
441 azimuth=spherical_list[2]
442 )
443 except Exception as e:
444 raise SolarPositionError(f"Failed to get sun direction spherical: {e}")
445
446 def setSunDirection(self, sundirection: SphericalCoord):
447 """
448 Override the computed solar position with a prescribed sun direction.
449
450 By default the sun position is computed from the date, time and location
451 set in the Context. Calling this method overrides that calculation, so
452 all subsequent sun queries (elevation, zenith, azimuth, direction
453 vectors) and flux calculations use the prescribed direction instead.
454
455 Args:
456 sundirection: SphericalCoord giving the direction of the sun.
457 Elevation and azimuth are in radians.
458
459 Raises:
460 ValueError: If sundirection is not a SphericalCoord
461 SolarPositionError: If the override fails
462
463 Example:
464 >>> from pyhelios.types import SphericalCoord
465 >>> import math
466 >>> solar.setSunDirection(SphericalCoord(1.0, math.radians(45), math.radians(180)))
467 >>> math.degrees(solar.getSunElevation())
468 45.0
469 """
470 if not isinstance(sundirection, SphericalCoord):
471 raise ValueError(
472 f"Sun direction must be a SphericalCoord, got {type(sundirection).__name__}"
473 )
474
476 try:
477 solar_wrapper.setSunDirection(
478 self._solar_pos,
479 sundirection.radius,
480 sundirection.elevation,
481 sundirection.azimuth,
482 )
483 except Exception as e:
484 raise SolarPositionError(f"Failed to set sun direction: {e}")
485
486 # Solar flux calculations
487 def getSolarFlux(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
488 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
489 """
490 Calculate total solar flux (supports legacy and modern APIs).
491
492 This method supports both legacy and modern APIs:
493 - **Legacy API**: Pass all 4 atmospheric parameters explicitly
494 - **Modern API**: Pass no parameters, uses atmospheric conditions from setAtmosphericConditions()
495
496 Args:
497 pressure_Pa: Atmospheric pressure in Pascals (e.g., 101325 for sea level) [optional]
498 temperature_K: Temperature in Kelvin (e.g., 288.15 for 15°C) [optional]
499 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
500 turbidity: Ångström's aerosol turbidity coefficient (β), the aerosol optical depth at 1 µm [optional]
501
502 Returns:
503 Total solar flux in W/m²
504
505 Raises:
506 ValueError: If some parameters provided but not all, or if values are invalid
507 SolarPositionError: If calculation fails or atmospheric conditions not set (modern API)
508
509 Note:
510 The model uses the total column ozone of getOzoneColumn(). Where the ozone climatology
511 has no data (high-latitude winter months, or poleward of 80 degrees) the calculation
512 fails until the ozone column is set with setOzoneColumn(). The same applies to
513 getSolarFluxPAR(), getSolarFluxNIR() and getDiffuseFraction().
514
515 Examples:
516 Legacy API (backward compatible):
517 >>> flux = solar.getSolarFlux(101325, 288.15, 0.6, 0.1)
518
519 Modern API (cleaner, reuses atmospheric state):
520 >>> solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
521 >>> flux = solar.getSolarFlux() # No parameters needed
522 """
523 # Determine which API pattern is being used
524 params_provided = [pressure_Pa is not None, temperature_K is not None,
525 humidity_rel is not None, turbidity is not None]
526
527 if all(params_provided):
528 # Legacy API: All parameters provided
530 try:
531 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
532 return solar_wrapper.getSolarFlux(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
533 except Exception as e:
534 raise SolarPositionError(f"Failed to calculate solar flux: {e}")
535
536 elif not any(params_provided):
537 # Modern API: No parameters, use atmospheric conditions from Context
539 try:
540 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
541 return solar_wrapper.getSolarFluxFromState(self._solar_pos)
542 except Exception as e:
543 raise SolarPositionError(
544 f"Failed to calculate solar flux from atmospheric state: {e}\n"
545 "Hint: Call setAtmosphericConditions() first to use parameter-free API, "
546 "or provide all 4 atmospheric parameters for legacy API."
547 )
549 else:
550 # Error: Partial parameters provided
551 raise ValueError(
552 "Either provide all atmospheric parameters (pressure_Pa, temperature_K, humidity_rel, turbidity) "
553 "or provide none to use atmospheric conditions from setAtmosphericConditions(). "
554 "Partial parameter sets are not supported."
555 )
556
557 def getSolarFluxPAR(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
558 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
559 """
560 Calculate PAR (Photosynthetically Active Radiation) solar flux.
561
562 Supports both legacy (parameter-based) and modern (state-based) APIs.
563
564 Args:
565 pressure_Pa: Atmospheric pressure in Pascals [optional]
566 temperature_K: Temperature in Kelvin [optional]
567 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
568 turbidity: Ångström's aerosol turbidity coefficient (β), the aerosol optical depth at 1 µm [optional]
569
570 Returns:
571 PAR solar flux in W/m² (wavelength range ~400-700 nm)
572
573 Raises:
574 ValueError: If some parameters provided but not all
575 SolarPositionError: If calculation fails
576
577 Examples:
578 Legacy: par_flux = solar.getSolarFluxPAR(101325, 288.15, 0.6, 0.1)
579 Modern: solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
580 par_flux = solar.getSolarFluxPAR()
581 """
582 params_provided = [pressure_Pa is not None, temperature_K is not None,
583 humidity_rel is not None, turbidity is not None]
584
585 if all(params_provided):
587 try:
588 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
589 return solar_wrapper.getSolarFluxPAR(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
590 except Exception as e:
591 raise SolarPositionError(f"Failed to calculate PAR flux: {e}")
592 elif not any(params_provided):
594 try:
595 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
596 return solar_wrapper.getSolarFluxPARFromState(self._solar_pos)
597 except Exception as e:
598 raise SolarPositionError(
599 f"Failed to calculate PAR flux from atmospheric state: {e}\n"
600 "Hint: Call setAtmosphericConditions() first."
601 )
602 else:
603 raise ValueError("Provide all atmospheric parameters or none (use setAtmosphericConditions()).")
604
605 def getSolarFluxNIR(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
606 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
607 """
608 Calculate NIR (Near-Infrared) solar flux.
609
610 Supports both legacy (parameter-based) and modern (state-based) APIs.
611
612 Args:
613 pressure_Pa: Atmospheric pressure in Pascals [optional]
614 temperature_K: Temperature in Kelvin [optional]
615 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
616 turbidity: Ångström's aerosol turbidity coefficient (β), the aerosol optical depth at 1 µm [optional]
617
618 Returns:
619 NIR solar flux in W/m² (wavelength range >700 nm)
620
621 Raises:
622 ValueError: If some parameters provided but not all
623 SolarPositionError: If calculation fails
624
625 Examples:
626 Legacy: nir_flux = solar.getSolarFluxNIR(101325, 288.15, 0.6, 0.1)
627 Modern: solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
628 nir_flux = solar.getSolarFluxNIR()
629 """
630 params_provided = [pressure_Pa is not None, temperature_K is not None,
631 humidity_rel is not None, turbidity is not None]
632
633 if all(params_provided):
635 try:
636 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
637 return solar_wrapper.getSolarFluxNIR(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
638 except Exception as e:
639 raise SolarPositionError(f"Failed to calculate NIR flux: {e}")
640 elif not any(params_provided):
642 try:
643 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
644 return solar_wrapper.getSolarFluxNIRFromState(self._solar_pos)
645 except Exception as e:
646 raise SolarPositionError(
647 f"Failed to calculate NIR flux from atmospheric state: {e}\n"
648 "Hint: Call setAtmosphericConditions() first."
649 )
650 else:
651 raise ValueError("Provide all atmospheric parameters or none (use setAtmosphericConditions()).")
652
653 def getDiffuseFraction(self, pressure_Pa: Optional[float] = None, temperature_K: Optional[float] = None,
654 humidity_rel: Optional[float] = None, turbidity: Optional[float] = None) -> float:
655 """
656 Calculate the diffuse fraction of solar radiation.
657
658 Supports both legacy (parameter-based) and modern (state-based) APIs.
659
660 Args:
661 pressure_Pa: Atmospheric pressure in Pascals [optional]
662 temperature_K: Temperature in Kelvin [optional]
663 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
664 turbidity: Ångström's aerosol turbidity coefficient (β), the aerosol optical depth at 1 µm [optional]
665
666 Returns:
667 Diffuse fraction as ratio (0.0-1.0) where:
668 - 0.0 = all direct radiation
669 - 1.0 = all diffuse radiation
670
671 Raises:
672 ValueError: If some parameters provided but not all
673 SolarPositionError: If calculation fails
674
675 Examples:
676 Legacy: diffuse = solar.getDiffuseFraction(101325, 288.15, 0.6, 0.1)
677 Modern: solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
678 diffuse = solar.getDiffuseFraction()
679 """
680 params_provided = [pressure_Pa is not None, temperature_K is not None,
681 humidity_rel is not None, turbidity is not None]
682
683 if all(params_provided):
685 try:
686 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
687 return solar_wrapper.getDiffuseFraction(self._solar_pos, pressure_Pa, temperature_K, humidity_rel, turbidity)
688 except Exception as e:
689 raise SolarPositionError(f"Failed to calculate diffuse fraction: {e}")
690 elif not any(params_provided):
692 try:
693 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
694 return solar_wrapper.getDiffuseFractionFromState(self._solar_pos)
695 except Exception as e:
696 raise SolarPositionError(
697 f"Failed to calculate diffuse fraction from atmospheric state: {e}\n"
698 "Hint: Call setAtmosphericConditions() first."
699 )
700 else:
701 raise ValueError("Provide all atmospheric parameters or none (use setAtmosphericConditions()).")
702
703 def getAmbientLongwaveFlux(self, temperature_K: Optional[float] = None,
704 humidity_rel: Optional[float] = None) -> float:
705 """
706 Calculate the ambient (sky) longwave radiation flux.
707
708 This method supports both legacy and modern APIs:
709 - **Legacy API**: Pass temperature and humidity explicitly
710 - **Modern API**: Pass no parameters, uses atmospheric conditions from setAtmosphericConditions()
711
712 Args:
713 temperature_K: Temperature in Kelvin [optional]
714 humidity_rel: Relative humidity as fraction (0.0-1.0) [optional]
715
716 Returns:
717 Ambient longwave flux in W/m²
718
719 Raises:
720 ValueError: If one parameter provided but not the other
721 SolarPositionError: If calculation fails
722
723 Note:
724 The longwave flux model is based on Prata (1996).
725 Returns downwelling longwave radiation flux on a horizontal surface, integrated over
726 all longwave wavelengths. It is the diffuse flux to give an emission band without
727 wavelength bounds. For the sky flux within a thermal band with wavelength bounds,
728 use getThermalSkyFlux().
729
730 Examples:
731 Legacy API:
732 >>> lw_flux = solar.getAmbientLongwaveFlux(288.15, 0.6)
733
734 Modern API (uses temperature and humidity from setAtmosphericConditions):
735 >>> solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
736 >>> lw_flux = solar.getAmbientLongwaveFlux()
737 """
738 params_provided = [temperature_K is not None, humidity_rel is not None]
739
740 if all(params_provided):
741 # Legacy API: Both parameters provided
742 # C++ has deprecated 2-parameter version, but we emulate it
743 # by setting atmospheric conditions temporarily
745 try:
746 # Get current conditions to restore later
747 saved_conditions = solar_wrapper.getAtmosphericConditions(self._solar_pos)
748
749 # Set temporary conditions with provided temperature and humidity
750 # Use current values for pressure and turbidity
751 solar_wrapper.setAtmosphericConditions(self._solar_pos,
752 saved_conditions[0], # pressure (unchanged)
753 temperature_K, # temperature (provided)
754 humidity_rel, # humidity (provided)
755 saved_conditions[3]) # turbidity (unchanged)
756
757 # Call parameter-free version
758 result = solar_wrapper.getAmbientLongwaveFluxFromState(self._solar_pos)
759
760 # Restore original conditions
761 solar_wrapper.setAtmosphericConditions(self._solar_pos, *saved_conditions)
762
763 return result
764
765 except Exception as e:
766 raise SolarPositionError(f"Failed to calculate ambient longwave flux: {e}")
767
768 elif not any(params_provided):
769 # Modern API: No parameters, use atmospheric conditions from Context
771 try:
772 return solar_wrapper.getAmbientLongwaveFluxFromState(self._solar_pos)
773 except Exception as e:
774 raise SolarPositionError(
775 f"Failed to calculate ambient longwave flux from atmospheric state: {e}\n"
776 "Hint: Call setAtmosphericConditions() first to use parameter-free API, "
777 "or provide temperature_K and humidity_rel for legacy API."
778 )
779
780 else:
781 # Error: Only one parameter provided
782 raise ValueError(
783 "Either provide both temperature_K and humidity_rel, "
784 "or provide neither to use atmospheric conditions from setAtmosphericConditions()."
785 )
786
787 # Time calculations
788 def getSunriseTime(self) -> Time:
789 """
790 Calculate sunrise time for the current date and location.
791
792 Returns:
793 Time object with sunrise time (hour, minute, second)
794
795 Raises:
796 SolarPositionError: If calculation fails
797
798 Example:
799 >>> sunrise = solar.getSunriseTime()
800 >>> print(f"Sunrise: {sunrise}") # Prints as HH:MM:SS
801 """
803 try:
804 hour, minute, second = solar_wrapper.getSunriseTime(self._solar_pos)
805 return Time(hour, minute, second)
806 except Exception as e:
807 raise SolarPositionError(f"Failed to calculate sunrise time: {e}")
808
809 def getSunsetTime(self) -> Time:
810 """
811 Calculate sunset time for the current date and location.
812
813 Returns:
814 Time object with sunset time (hour, minute, second)
815
816 Raises:
817 SolarPositionError: If calculation fails
818
819 Example:
820 >>> sunset = solar.getSunsetTime()
821 >>> print(f"Sunset: {sunset}") # Prints as HH:MM:SS
822 """
824 try:
825 hour, minute, second = solar_wrapper.getSunsetTime(self._solar_pos)
826 return Time(hour, minute, second)
827 except Exception as e:
828 raise SolarPositionError(f"Failed to calculate sunset time: {e}")
829
830 # Calibration functions
831 def calibrateTurbidityFromTimeseries(self, timeseries_label: str) -> float:
832 """
833 Calibrate atmospheric turbidity using timeseries data.
834
835 Args:
836 timeseries_label: Label of timeseries data in Context. The data should
837 be global shortwave radiation flux on a horizontal plane in W/m^2,
838 and should contain at least one day of clear-sky conditions.
839
840 Returns:
841 The calibrated turbidity value
842
843 Raises:
844 ValueError: If timeseries label is invalid
845 SolarPositionError: If calibration fails
846
847 Note:
848 This method does not itself apply the calibrated value. Pass the
849 returned turbidity to setAtmosphericConditions() to use it.
850
851 Example:
852 >>> turbidity = solar.calibrateTurbidityFromTimeseries("solar_irradiance")
853 >>> solar.setAtmosphericConditions(101325, 293.15, 0.5, turbidity)
854 """
855 if not timeseries_label:
856 raise ValueError("Timeseries label cannot be empty")
857
859 try:
860 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
861 return solar_wrapper.calibrateTurbidityFromTimeseries(self._solar_pos, timeseries_label)
862 except Exception as e:
863 raise SolarPositionError(f"Failed to calibrate turbidity: {e}")
864
865 def enableCloudCalibration(self, timeseries_label: str):
866 """
867 Enable cloud calibration using timeseries data.
868
869 Args:
870 timeseries_label: Label of cloud timeseries data in Context
871
872 Raises:
873 ValueError: If timeseries label is invalid
874 SolarPositionError: If calibration setup fails
875
876 Example:
877 >>> solar.enableCloudCalibration("cloud_cover")
878 """
879 if not timeseries_label:
880 raise ValueError("Timeseries label cannot be empty")
883 try:
884 solar_wrapper.enableCloudCalibration(self._solar_pos, timeseries_label)
885 except Exception as e:
886 raise SolarPositionError(f"Failed to enable cloud calibration: {e}")
887
888 def disableCloudCalibration(self):
889 """
890 Disable cloud calibration.
891
892 Raises:
893 SolarPositionError: If operation fails
894
895 Example:
896 >>> solar.disableCloudCalibration()
897 """
899 try:
900 solar_wrapper.disableCloudCalibration(self._solar_pos)
901 except Exception as e:
902 raise SolarPositionError(f"Failed to disable cloud calibration: {e}")
903
904 # Prague Sky Model Methods (v1.3.59+)
906 """
907 Enable Prague Sky Model for physically-based sky radiance calculations.
908
909 The Prague Sky Model provides high-quality spectral and angular sky radiance
910 distribution for accurate diffuse radiation modeling. It accounts for Rayleigh
911 and Mie scattering to produce realistic sky radiance patterns across the
912 360-1480 nm spectral range.
913
914 Raises:
915 SolarPositionError: If operation fails
916
917 Note:
918 After enabling, call updatePragueSkyModel() to compute and store spectral-angular
919 parameters in Context global data. Requires ~27 MB data file:
920 plugins/solarposition/lib/prague_sky_model/PragueSkyModelReduced.dat
921
922 Example:
923 >>> with Context() as context:
924 ... with SolarPosition(context) as solar:
925 ... solar.enablePragueSkyModel()
926 ... solar.updatePragueSkyModel()
927 """
929 try:
931 solar_wrapper.enablePragueSkyModel(self._solar_pos)
932 except Exception as e:
933 raise SolarPositionError(f"Failed to enable Prague Sky Model: {e}")
934
935 def isPragueSkyModelEnabled(self) -> bool:
936 """
937 Check if Prague Sky Model is currently enabled.
938
939 Returns:
940 True if Prague Sky Model has been enabled via enablePragueSkyModel(), False otherwise
941
942 Raises:
943 SolarPositionError: If operation fails
944
945 Example:
946 >>> if solar.isPragueSkyModelEnabled():
947 ... print("Prague Sky Model is active")
948 """
950 try:
951 return solar_wrapper.isPragueSkyModelEnabled(self._solar_pos)
952 except Exception as e:
953 raise SolarPositionError(f"Failed to check Prague Sky Model status: {e}")
955 def updatePragueSkyModel(self, ground_albedo: float = 0.33):
956 """
957 Update Prague Sky Model and store spectral-angular parameters in Context.
958
959 This is a computationally intensive operation (~1100 model queries with OpenMP
960 parallelization) that computes sky radiance distribution for current atmospheric
961 and solar conditions. Use pragueSkyModelNeedsUpdate() for lazy evaluation to
962 avoid unnecessary updates.
963
964 Args:
965 ground_albedo: Ground surface albedo (default: 0.33 for typical soil/vegetation)
966
967 Raises:
968 SolarPositionError: If update fails
969
970 Note:
971 Reads turbidity (Ångström's β, the aerosol optical depth at 1 µm) from Context
972 atmospheric conditions and converts the aerosol optical depth at 550 nm to the
973 visibility that parameterizes the Prague model, limited to its range of
974 20-131.8 km: turbidities below about 0.029 (including the default of 0.02) use the
975 cleanest Prague atmosphere and those above about 0.21 the haziest. Stores results in Context
976 global data as "prague_sky_spectral_params" (1350 floats: 225 wavelengths × 6 params),
977 "prague_sky_sun_direction", "prague_sky_visibility_km", "prague_sky_ground_albedo",
978 and "prague_sky_valid" flag.
979
980 Example:
981 >>> solar.setAtmosphericConditions(101325, 288.15, 0.6, 0.1)
982 >>> solar.updatePragueSkyModel(ground_albedo=0.25)
983 """
985 try:
987 solar_wrapper.updatePragueSkyModel(self._solar_pos, ground_albedo)
988 except Exception as e:
989 raise SolarPositionError(f"Failed to update Prague Sky Model: {e}")
990
991 def pragueSkyModelNeedsUpdate(self, ground_albedo: float = 0.33,
992 sun_tolerance: float = 0.01,
993 turbidity_tolerance: float = 0.02,
994 albedo_tolerance: float = 0.05) -> bool:
995 """
996 Check if Prague Sky Model needs updating based on changed conditions.
997
998 Enables lazy evaluation to avoid expensive Prague updates when conditions haven't
999 changed significantly. Compares current state against cached values.
1000
1001 Args:
1002 ground_albedo: Current ground albedo (default: 0.33)
1003 sun_tolerance: Threshold for sun direction changes (default: 0.01 ≈ 0.57°)
1004 turbidity_tolerance: Relative threshold for turbidity (default: 0.02 = 2%)
1005 albedo_tolerance: Threshold for albedo changes (default: 0.05 = 5%)
1006
1007 Returns:
1008 True if updatePragueSkyModel() should be called, False if cached data is valid
1009
1010 Raises:
1011 SolarPositionError: If check fails
1012
1013 Note:
1014 Reads turbidity from Context atmospheric conditions for comparison.
1015
1016 Example:
1017 >>> if solar.pragueSkyModelNeedsUpdate():
1018 ... solar.updatePragueSkyModel()
1019 """
1021 try:
1022 return solar_wrapper.pragueSkyModelNeedsUpdate(self._solar_pos, ground_albedo,
1023 sun_tolerance, turbidity_tolerance,
1024 albedo_tolerance)
1025 except Exception as e:
1026 raise SolarPositionError(f"Failed to check Prague Sky Model update status: {e}")
1027
1028 # SSolar-GOA Spectral Solar Model Methods
1029 def calculateDirectSolarSpectrum(self, label: str, resolution_nm: float = 1.0):
1030 """
1031 Calculate direct beam solar spectrum.
1032
1033 Computes the spectral irradiance of the direct solar beam on a surface normal to
1034 the sun direction from 300 to 2600 nm, with a spectral model that follows SSolar-GOA
1035 (Cachorro et al. 2022). Results are stored in Context global data as a vector of
1036 (wavelength, irradiance) pairs.
1037
1038 Args:
1039 label: Label to store the spectrum data in Context global data
1040 resolution_nm: Wavelength resolution in nanometers (1.0-2300.0).
1041 Lower values give finer spectral resolution but require
1042 more computation. Default is 1.0 nm.
1044 Raises:
1045 ValueError: If label is empty or resolution is out of valid range
1046 SolarPositionError: If calculation fails
1047
1048 Note:
1049 - Requires Context time/date to be set for accurate solar position
1050 - Atmospheric conditions are taken from setAtmosphericConditions(), the ozone column
1051 from getOzoneColumn() and the ground albedo from setGroundAlbedo(); cloud
1052 calibration is applied if enabled with enableCloudCalibration()
1053 - The spectrum is referred to by its label, e.g. in RadiationModel.setSourceSpectrum()
1054 or RadiationModel.setDiffuseSpectrum(); context.getGlobalDataSize(label) gives its
1055 number of wavelengths
1056 - Fails if the sun is at or below the horizon, if the relative humidity is not
1057 greater than zero, or if no ozone column was set and the climatology has no data
1058 for the location and date (see getOzoneColumn())
1059
1060 Example:
1061 >>> with Context() as context:
1062 ... context.setDate(2023, 6, 21)
1063 ... context.setTime(12, 0)
1064 ... with SolarPosition(context) as solar:
1065 ... solar.calculateDirectSolarSpectrum("direct_spectrum", resolution_nm=5.0)
1066 ... n_wavelengths = context.getGlobalDataSize("direct_spectrum") # 461
1067 """
1068 if not label:
1069 raise ValueError("Label cannot be empty")
1070 if resolution_nm < 1.0 or resolution_nm > 2300.0:
1071 raise ValueError(f"Wavelength resolution must be between 1 and 2300 nm, got: {resolution_nm}")
1072
1074 try:
1075 with _solarposition_working_directory(_SPECTRAL_MODEL_ASSETS):
1076 solar_wrapper.calculateDirectSolarSpectrum(self._solar_pos, label, resolution_nm)
1077 except Exception as e:
1078 raise SolarPositionError(f"Failed to calculate direct solar spectrum: {e}")
1079
1080 def calculateDiffuseSolarSpectrum(self, label: str, resolution_nm: float = 1.0):
1081 """
1082 Calculate diffuse solar spectrum.
1083
1084 Computes the diffuse (sky) spectral irradiance on a horizontal surface from 300 to
1085 2600 nm, including light reflected by the ground and scattered back down by the
1086 atmosphere, with a spectral model that follows SSolar-GOA (Cachorro et al. 2022).
1087 Results are stored in Context global data as a vector of (wavelength, irradiance) pairs.
1088
1089 Args:
1090 label: Label to store the spectrum data in Context global data
1091 resolution_nm: Wavelength resolution in nanometers (1.0-2300.0).
1092 Lower values give finer spectral resolution but require
1093 more computation. Default is 1.0 nm.
1095 Raises:
1096 ValueError: If label is empty or resolution is out of valid range
1097 SolarPositionError: If calculation fails
1098
1099 Note:
1100 - Requires Context time/date to be set for accurate solar position
1101 - Atmospheric conditions are taken from setAtmosphericConditions(), the ozone column
1102 from getOzoneColumn() and the ground albedo from setGroundAlbedo(); cloud
1103 calibration is applied if enabled with enableCloudCalibration()
1104 - The spectrum is referred to by its label, e.g. in RadiationModel.setSourceSpectrum()
1105 or RadiationModel.setDiffuseSpectrum(); context.getGlobalDataSize(label) gives its
1106 number of wavelengths
1107 - Fails if the sun is at or below the horizon, if the relative humidity is not
1108 greater than zero, or if no ozone column was set and the climatology has no data
1109 for the location and date (see getOzoneColumn())
1110 - Diffuse radiation results from atmospheric scattering (Rayleigh, aerosol)
1111
1112 Example:
1113 >>> with Context() as context:
1114 ... context.setDate(2023, 6, 21)
1115 ... context.setTime(12, 0)
1116 ... with SolarPosition(context) as solar:
1117 ... solar.calculateDiffuseSolarSpectrum("diffuse_spectrum", resolution_nm=5.0)
1118 ... n_wavelengths = context.getGlobalDataSize("diffuse_spectrum") # 461
1119 """
1120 if not label:
1121 raise ValueError("Label cannot be empty")
1122 if resolution_nm < 1.0 or resolution_nm > 2300.0:
1123 raise ValueError(f"Wavelength resolution must be between 1 and 2300 nm, got: {resolution_nm}")
1124
1126 try:
1127 with _solarposition_working_directory(_SPECTRAL_MODEL_ASSETS):
1128 solar_wrapper.calculateDiffuseSolarSpectrum(self._solar_pos, label, resolution_nm)
1129 except Exception as e:
1130 raise SolarPositionError(f"Failed to calculate diffuse solar spectrum: {e}")
1131
1132 def calculateGlobalSolarSpectrum(self, label: str, resolution_nm: float = 1.0):
1133 """
1134 Calculate global (total) solar spectrum.
1135
1136 Computes the global (direct plus diffuse) spectral irradiance on a horizontal surface
1137 from 300 to 2600 nm, with a spectral model that follows SSolar-GOA (Cachorro et al. 2022).
1138 Results are stored in Context global data as a vector of (wavelength, irradiance) pairs.
1139
1140 Args:
1141 label: Label to store the spectrum data in Context global data
1142 resolution_nm: Wavelength resolution in nanometers (1.0-2300.0).
1143 Lower values give finer spectral resolution but require
1144 more computation. Default is 1.0 nm.
1145
1146 Raises:
1147 ValueError: If label is empty or resolution is out of valid range
1148 SolarPositionError: If calculation fails
1149
1150 Note:
1151 - Requires Context time/date to be set for accurate solar position
1152 - Atmospheric conditions are taken from setAtmosphericConditions(), the ozone column
1153 from getOzoneColumn() and the ground albedo from setGroundAlbedo(); cloud
1154 calibration is applied if enabled with enableCloudCalibration()
1155 - The spectrum is referred to by its label, e.g. in RadiationModel.setSourceSpectrum()
1156 or RadiationModel.setDiffuseSpectrum(); context.getGlobalDataSize(label) gives its
1157 number of wavelengths
1158 - Fails if the sun is at or below the horizon, if the relative humidity is not
1159 greater than zero, or if no ozone column was set and the climatology has no data
1160 for the location and date (see getOzoneColumn())
1161 - Global spectrum = direct beam + diffuse (sky) radiation
1162 - Most useful for plant canopy modeling and photosynthesis calculations
1163
1164 Example:
1165 >>> with Context() as context:
1166 ... context.setDate(2023, 6, 21)
1167 ... context.setTime(12, 0)
1168 ... with SolarPosition(context) as solar:
1169 ... solar.calculateGlobalSolarSpectrum("global_spectrum", resolution_nm=10.0)
1170 ... n_wavelengths = context.getGlobalDataSize("global_spectrum") # 231
1171 """
1172 if not label:
1173 raise ValueError("Label cannot be empty")
1174 if resolution_nm < 1.0 or resolution_nm > 2300.0:
1175 raise ValueError(f"Wavelength resolution must be between 1 and 2300 nm, got: {resolution_nm}")
1176
1178 try:
1179 with _solarposition_working_directory(_SPECTRAL_MODEL_ASSETS):
1180 solar_wrapper.calculateGlobalSolarSpectrum(self._solar_pos, label, resolution_nm)
1181 except Exception as e:
1182 raise SolarPositionError(f"Failed to calculate global solar spectrum: {e}")
1183
1184 # Ozone column and ground albedo (v1.3.90+)
1185 def setOzoneColumn(self, ozone_DU: float) -> None:
1186 """
1187 Set the total column ozone, overriding the climatological value.
1188
1189 The ozone column is used by the broadband solar flux model (getSolarFlux() family), the
1190 spectral solar irradiance model and the sensor atmosphere models. It is stored in Context
1191 global data as "atmosphere_ozone_DU".
1192
1193 Args:
1194 ozone_DU: Total column ozone in Dobson units (must be > 0)
1195
1196 Raises:
1197 ValueError: If ozone_DU is not a positive number
1198 SolarPositionError: If operation fails
1199
1200 Example:
1201 >>> solar.setOzoneColumn(310.0) # e.g., from a local measurement
1202 """
1203 if isinstance(ozone_DU, bool) or not isinstance(ozone_DU, (int, float)):
1204 raise ValueError(f"Ozone column must be a number, got {type(ozone_DU).__name__}")
1205 if not ozone_DU > 0.0:
1206 raise ValueError(f"Ozone column must be positive, got: {ozone_DU}")
1207
1209 try:
1210 solar_wrapper.setOzoneColumn(self._solar_pos, ozone_DU)
1211 except Exception as e:
1212 raise SolarPositionError(f"Failed to set ozone column: {e}")
1213
1214 def getOzoneColumn(self) -> float:
1215 """
1216 Get the total column ozone used by the solar radiation models.
1217
1218 Returns the ozone column set with setOzoneColumn(), if any. Otherwise returns the monthly
1219 zonal-mean climatology of the NASA SBUV Merged Ozone Data Set averaged over 2005-2024,
1220 interpolated linearly in latitude between the centers of its 5-degree latitude bands and in
1221 the day of the year between the middles of the months.
1222
1223 Returns:
1224 Total column ozone in Dobson units
1225
1226 Raises:
1227 SolarPositionError: If no ozone column was set and the climatology has no data for the
1228 location and date. The climatology has no data in high-latitude winter months or
1229 poleward of 80 degrees; set the ozone column with setOzoneColumn() there.
1230
1231 Note:
1232 Next to a latitude band or month without data, the value of the band or month containing
1233 the location or date is used rather than interpolating toward the missing one.
1234
1235 Example:
1236 >>> ozone_DU = solar.getOzoneColumn()
1237 """
1239 try:
1240 with _solarposition_working_directory((_OZONE_CLIMATOLOGY,)):
1241 return solar_wrapper.getOzoneColumn(self._solar_pos)
1242 except Exception as e:
1243 raise SolarPositionError(f"Failed to get ozone column: {e}")
1244
1245 def setGroundAlbedo(self, albedo: float) -> None:
1246 """
1247 Set the albedo of the ground surrounding the scene.
1248
1249 The ground albedo sets how much light is reflected back and forth between the ground and
1250 the atmosphere, which increases the global and diffuse irradiance, most strongly at short
1251 wavelengths. It is stored in Context global data as "atmosphere_ground_albedo".
1252
1253 Args:
1254 albedo: Broadband ground albedo (must be between 0 and 1)
1255
1256 Raises:
1257 ValueError: If albedo is not a number between 0 and 1
1258 SolarPositionError: If operation fails
1259
1260 Note:
1261 The ground albedo affects the global and diffuse spectra of calculateGlobalSolarSpectrum()
1262 and calculateDiffuseSolarSpectrum(), and the ground irradiance and adjacency radiance of
1263 calculateSensorAtmosphereSpectra(); without cloud calibration it does not affect the direct
1264 beam. It does not affect the broadband getSolarFlux() family of methods, and the Prague sky
1265 model takes its ground albedo as an argument of updatePragueSkyModel().
1266
1267 Example:
1268 >>> solar.setGroundAlbedo(0.8) # e.g., fresh snow
1269 """
1270 if isinstance(albedo, bool) or not isinstance(albedo, (int, float)):
1271 raise ValueError(f"Ground albedo must be a number, got {type(albedo).__name__}")
1272 if not 0.0 <= albedo <= 1.0:
1273 raise ValueError(f"Ground albedo must be between 0 and 1, got: {albedo}")
1274
1276 try:
1277 solar_wrapper.setGroundAlbedo(self._solar_pos, albedo)
1278 except Exception as e:
1279 raise SolarPositionError(f"Failed to set ground albedo: {e}")
1280
1281 def getGroundAlbedo(self) -> float:
1282 """
1283 Get the albedo of the ground surrounding the scene.
1284
1285 Returns:
1286 Ground albedo set by setGroundAlbedo(), or 0.2 if it has not been set
1287
1288 Raises:
1289 SolarPositionError: If operation fails
1290
1291 Example:
1292 >>> albedo = solar.getGroundAlbedo()
1293 """
1295 try:
1296 return solar_wrapper.getGroundAlbedo(self._solar_pos)
1297 except Exception as e:
1298 raise SolarPositionError(f"Failed to get ground albedo: {e}")
1299
1300 # Sensor atmosphere methods (v1.3.90+)
1301 def calculateSensorAtmosphereSpectra(self, label: str, direction_to_sensor: vec3,
1302 resolution_nm: float = 1.0) -> None:
1303 """
1304 Calculate the spectral atmospheric quantities needed to simulate imagery from a sensor
1305 above the atmosphere (e.g., a satellite).
1306
1307 Radiance reaching the sensor is modeled as L_sensor = L_path + T_dir_up * L_surface + L_adj,
1308 where L_surface is the radiance leaving the scene. The atmosphere is described by a look-up
1309 table computed with the 6S radiative transfer code (continental aerosol), which gives both
1310 the upward path to the sensor and the downward solar illumination of the scene. For the two
1311 to be consistent, illuminate the scene with the "_direct_irradiance" and
1312 "_diffuse_irradiance" spectra stored by this method rather than those of
1313 calculateDirectSolarSpectrum() and calculateDiffuseSolarSpectrum().
1314
1315 The following spectra are stored in Context global data as vectors of vec2
1316 (wavelength_nm, value), each labeled with the given label followed by a suffix:
1317
1318 - "_path_radiance": radiance scattered by the atmosphere into the sensor without reaching
1319 the ground (W/m²/sr/nm)
1320 - "_adjacency_radiance": radiance reflected by the surrounding ground (albedo from
1321 setGroundAlbedo()) and scattered by the atmosphere into the sensor (W/m²/sr/nm)
1322 - "_upward_direct_transmittance": transmittance of the surface-leaving radiance straight to
1323 the sensor, including gas absorption
1324 - "_upward_diffuse_transmittance": transmittance of surface-leaving radiance scattered into
1325 the sensor direction, including gas absorption
1326 - "_spherical_albedo": spherical albedo of the atmosphere
1327 - "_direct_irradiance": direct solar irradiance at the ground, normal to the sun direction
1328 (W/m²/nm)
1329 - "_diffuse_irradiance": diffuse solar irradiance at the ground on a horizontal surface
1330 (W/m²/nm)
1331 - "_global_irradiance": global solar irradiance at the ground on a horizontal surface
1332 (W/m²/nm)
1333
1334 The unit vector toward the sensor is also stored, as vec3 global data with the suffix
1335 "_direction_to_sensor".
1336
1337 Args:
1338 label: Prefix of the labels under which the spectra are stored in Context global data
1339 direction_to_sensor: Vector pointing from the scene toward the sensor (need not be
1340 normalized). Its zenith angle must not exceed 60 degrees.
1341 resolution_nm: Wavelength resolution in nanometers (1.0-2300.0). Default is 1.0 nm.
1342
1343 Raises:
1344 ValueError: If label is empty, direction_to_sensor is not a non-zero vec3, or
1345 resolution is out of valid range
1346 SolarPositionError: If calculation fails, including for conditions outside the range of
1347 the look-up table
1348
1349 Note:
1350 - Atmospheric conditions are taken from setAtmosphericConditions(), the ozone column
1351 from getOzoneColumn() and the ground albedo from setGroundAlbedo(). The turbidity is
1352 converted to aerosol optical depth at 550 nm with an Ångström exponent of 1.3.
1353 - The look-up table covers solar zenith angles up to 70 degrees, view zenith angles up
1354 to 60 degrees, aerosol optical depths at 550 nm up to 1.2 (turbidity up to about 0.55)
1355 and surface pressures from 701.2 to 1013 hPa (ground elevations up to about 3 km);
1356 pressures up to 1050 hPa are extrapolated linearly. Its gas tables cover water vapor
1357 paths up to 320 g/cm². Conditions outside these ranges, and a sun below the horizon,
1358 raise an error.
1359 - The sensor is taken to be above the atmosphere. The model assumes clear sky and
1360 cannot be used with cloud calibration enabled.
1361 - RadiationModel.enableCameraAtmosphere() applies these spectra to a camera's images.
1362
1363 Example:
1364 >>> solar.setAtmosphericConditions(101325, 298, 0.5, 0.1)
1365 >>> solar.setGroundAlbedo(0.15)
1366 >>> solar.calculateSensorAtmosphereSpectra("satellite", vec3(0, 0, 1)) # sensor at nadir
1367 >>> radiation.setSourceSpectrum(sun_source, "satellite_direct_irradiance")
1368 >>> radiation.setDiffuseSpectrum("red", "satellite_diffuse_irradiance")
1369 """
1370 _validate_sensor_arguments(label, direction_to_sensor)
1371 if isinstance(resolution_nm, bool) or not isinstance(resolution_nm, (int, float)):
1372 raise ValueError(f"Wavelength resolution must be a number, got {type(resolution_nm).__name__}")
1373 if resolution_nm < 1.0 or resolution_nm > 2300.0:
1374 raise ValueError(f"Wavelength resolution must be between 1 and 2300 nm, got: {resolution_nm}")
1375
1377 try:
1378 with _solarposition_working_directory(_SPECTRAL_MODEL_ASSETS):
1379 solar_wrapper.calculateSensorAtmosphereSpectra(self._solar_pos, label,
1380 direction_to_sensor.to_list(), resolution_nm)
1381 except Exception as e:
1382 raise SolarPositionError(f"Failed to calculate sensor atmosphere spectra: {e}")
1383
1384 def calculateSensorThermalAtmosphere(self, label: str, direction_to_sensor: vec3) -> None:
1385 """
1386 Calculate the thermal infrared atmospheric quantities needed to simulate thermal imagery
1387 from a sensor above the atmosphere (e.g., a satellite).
1388
1389 Radiance reaching the sensor at a thermal wavelength is modeled as
1390 L_sensor = tau * L_surface + L_up, where L_surface is the radiance leaving the scene
1391 (emitted plus reflected sky radiance), tau the atmospheric transmittance and L_up the
1392 radiance emitted upward by the atmosphere. These are interpolated from a look-up table
1393 computed with the libRadtran radiative transfer code (clear sky without aerosol).
1394
1395 The following spectra are stored in Context global data as vectors of vec2
1396 (wavelength of the bin center in nm, value), with 234 points from 5502 to 15326 nm, each
1397 labeled with the given label followed by a suffix:
1398
1399 - "_thermal_transmittance": atmospheric transmittance from the ground to the sensor
1400 - "_thermal_upwelling_radiance": radiance emitted by the atmosphere toward the sensor
1401 (W/m²/sr/nm)
1402 - "_thermal_downwelling_radiance": sky irradiance on a horizontal surface at the ground
1403 divided by pi (W/m²/sr/nm), independent of the view direction
1404
1405 The column water vapor (float, g/cm²) is stored with the suffix "_thermal_water_vapor_cm",
1406 and the unit vector toward the sensor, as vec3, with the suffix "_direction_to_sensor".
1407
1408 Args:
1409 label: Prefix of the labels under which the results are stored in Context global data
1410 direction_to_sensor: Vector pointing from the scene toward the sensor (need not be
1411 normalized). Its zenith angle must not exceed 60 degrees.
1412
1413 Raises:
1414 ValueError: If label is empty or direction_to_sensor is not a non-zero vec3
1415 SolarPositionError: If calculation fails, including for conditions outside the range of
1416 the look-up table
1417
1418 Note:
1419 - The atmospheric profile is built from the surface air temperature, humidity and
1420 pressure set with setAtmosphericConditions() and the ozone column of
1421 getOzoneColumn(). The column water vapor, derived from the temperature and humidity,
1422 must lie within 0.1-7.5 g/cm², the pressure within 701.2-1050 hPa (pressures above
1423 1013 hPa are extrapolated) and the ozone column within 150-450 DU. The supported air
1424 temperatures depend on the pressure: 246.2-321.7 K at 1013 hPa.
1425 - The model assumes clear sky and does not depend on the position of the sun, so
1426 night-time imagery can be simulated. Reflected sunlight, which is significant below
1427 about 5 µm, is not included.
1428
1429 Example:
1430 >>> solar.setAtmosphericConditions(101325, 298, 0.5, 0.1)
1431 >>> solar.calculateSensorThermalAtmosphere("satellite", vec3(0, 0, 1)) # sensor at nadir
1432 >>> context.getGlobalDataSize("satellite_thermal_transmittance")
1433 234
1434 """
1435 _validate_sensor_arguments(label, direction_to_sensor)
1436
1438 try:
1439 with _solarposition_working_directory(_THERMAL_MODEL_ASSETS):
1440 solar_wrapper.calculateSensorThermalAtmosphere(self._solar_pos, label,
1441 direction_to_sensor.to_list())
1442 except Exception as e:
1443 raise SolarPositionError(f"Failed to calculate sensor thermal atmosphere: {e}")
1444
1445 def getThermalSkyFlux(self, wavelength_min_nm: float, wavelength_max_nm: float) -> float:
1446 """
1447 Calculate the sky longwave irradiance on a horizontal surface within a thermal band.
1448
1449 This is the band-specific counterpart of getAmbientLongwaveFlux(): the downward irradiance
1450 emitted by a clear-sky atmosphere between two wavelengths, integrated from the downwelling
1451 radiance of the thermal atmosphere look-up table (see calculateSensorThermalAtmosphere()).
1452 It is the diffuse flux to give an emission band with the same wavelength bounds
1453 (RadiationModel.setDiffuseRadiationFlux()), so that the sky radiance the scene reflects is
1454 consistent with the thermal sensor atmosphere.
1455
1456 Args:
1457 wavelength_min_nm: Lower wavelength bound of the band in nm
1458 wavelength_max_nm: Upper wavelength bound of the band in nm
1459
1460 Returns:
1461 Sky longwave irradiance within the band on a horizontal surface, in W/m²
1462
1463 Raises:
1464 ValueError: If the bounds are not numbers satisfying 0 < wavelength_min_nm < wavelength_max_nm
1465 SolarPositionError: If calculation fails, including for bounds outside 5502-15326 nm or
1466 atmospheric conditions outside the ranges given for calculateSensorThermalAtmosphere()
1467
1468 Example:
1469 >>> radiation.addRadiationBand("TIR", 10600, 11190) # e.g., Landsat 8 TIRS band 10
1470 >>> radiation.setDiffuseRadiationFlux("TIR", solar.getThermalSkyFlux(10600, 11190))
1471 """
1472 for name, value in (("wavelength_min_nm", wavelength_min_nm), ("wavelength_max_nm", wavelength_max_nm)):
1473 if isinstance(value, bool) or not isinstance(value, (int, float)):
1474 raise ValueError(f"{name} must be a number, got {type(value).__name__}")
1475 if wavelength_min_nm <= 0.0 or wavelength_max_nm <= wavelength_min_nm:
1476 raise ValueError(
1477 f"Wavelength bounds must satisfy 0 < wavelength_min_nm < wavelength_max_nm, "
1478 f"got: ({wavelength_min_nm}, {wavelength_max_nm})")
1479
1481 try:
1482 with _solarposition_working_directory(_THERMAL_MODEL_ASSETS):
1483 return solar_wrapper.getThermalSkyFlux(self._solar_pos, wavelength_min_nm, wavelength_max_nm)
1484 except Exception as e:
1485 raise SolarPositionError(f"Failed to calculate thermal sky flux: {e}")
1486
1487 def is_available(self) -> bool:
1488 """
1489 Check if SolarPosition is available in current build.
1490
1491 Returns:
1492 True if plugin is available, False otherwise
1493 """
1494 registry = get_plugin_registry()
1495 return registry.is_plugin_available('solarposition')
1496
1497
1498# Convenience function
1499def create_solar_position(context: Context, utc_offset: Optional[float] = None,
1500 latitude: Optional[float] = None, longitude: Optional[float] = None) -> SolarPosition:
1501 """
1502 Create SolarPosition instance with context and optional coordinates.
1503
1504 Args:
1505 context: Helios Context
1506 utc_offset: UTC time offset in hours (optional)
1507 latitude: Latitude in degrees (optional)
1508 longitude: Longitude in degrees (optional)
1509
1510 Returns:
1511 SolarPosition instance
1512
1513 Example:
1514 >>> solar = create_solar_position(context, utc_offset=-8, latitude=38.5, longitude=-121.7)
1515 """
1516 return SolarPosition(context, utc_offset, latitude, longitude)
Exception raised for SolarPosition-specific errors.
High-level interface for solar position calculations and radiation modeling.
float getOzoneColumn(self)
Get the total column ozone used by the solar radiation models.
_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.
float getThermalSkyFlux(self, float wavelength_min_nm, float wavelength_max_nm)
Calculate the sky longwave irradiance on a horizontal surface within a thermal band.
float calibrateTurbidityFromTimeseries(self, str timeseries_label)
Calibrate atmospheric turbidity using timeseries data.
enableCloudCalibration(self, str timeseries_label)
Enable cloud calibration using timeseries data.
None setGroundAlbedo(self, float albedo)
Set the albedo of the ground surrounding the scene.
Time getSunriseTime(self)
Calculate sunrise time for the current date and location.
None setOzoneColumn(self, float ozone_DU)
Set the total column ozone, overriding the climatological value.
float getSunZenith(self)
Get the sun zenith angle in radians.
float getSunAzimuth(self)
Get the sun azimuth angle in radians.
None calculateSensorAtmosphereSpectra(self, str label, vec3 direction_to_sensor, float resolution_nm=1.0)
Calculate the spectral atmospheric quantities needed to simulate imagery from a sensor above the atmo...
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 getGroundAlbedo(self)
Get the albedo of the ground surrounding the scene.
None calculateSensorThermalAtmosphere(self, str label, vec3 direction_to_sensor)
Calculate the thermal infrared atmospheric quantities needed to simulate thermal imagery from a senso...
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.
calculateGlobalSolarSpectrum(self, str label, float resolution_nm=1.0)
Calculate global (total) solar spectrum.
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.
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:841
_solarposition_working_directory(Tuple[str,...] required_assets=(_PRAGUE_DATASET,))
Context manager that temporarily changes working directory to where SolarPosition assets are located.
None _validate_sensor_arguments(label, direction_to_sensor)
Validate the label and sensor direction shared by the sensor atmosphere methods.
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.