import os
from scipy.interpolate import interp1d
import numpy as np
from typing import Callable, Optional
from functools import wraps
from solcore.science_tracker import science_reference
from solcore import (
spectral_conversion_nm_ev,
spectral_conversion_nm_hz,
eVnm,
nmHz,
nmJ,
)
from solcore.constants import q, h, c, kb
from solcore.light_source.spectral2 import (
get_default_spectral2_object,
calculate_spectrum_spectral2,
)
from solcore.light_source.smarts import (
get_default_smarts_object,
calculate_spectrum_smarts,
)
REGISTERED_CONVERTERS: dict = {}
""" Registered spectrum conversion functions."""
[docs]def reference_spectra():
""" Function providing the standard reference spectra: AM0, AM1.5g and AM1.5d.
:return: A 2D array with 4 columns representing the wavelength, AM0, AM1.5g and
AM1.5d standard spectra.
"""
science_reference(
"Standard solar spectra",
"ASTM G173-03(2012), Standard Tables for Reference Solar Spectral Irradiances: "
"Direct Normal and Hemispherical on 37° Tilted Surface, ASTM International, "
"West Conshohocken, PA, 2012, www.astm.org",
)
this_dir = os.path.split(__file__)[0]
output = np.loadtxt(
os.path.join(this_dir, "astmg173.csv"), dtype=float, delimiter=",", skiprows=2
)
return output
[docs]class LightSource:
""" Common interface to access all types of light sources supported by Solcore.
It includes standard solar spectra (AM0, AM1.5g, and AM1.5d), blackbody radiation,
laser light or spectra created from atmospheric data using SPECTRAL2 or SMARTS.
Additionally, it can also use experimentally measured spectra.
"""
type_of_source = [
"laser",
"black body",
"standard",
"SMARTS",
"SPECTRAL2",
"custom",
]
def __init__(self, source_type, x=None, output_units = "power_density_per_nm", concentration=1, **kwargs):
"""
:param source_type:
:param kwargs:
"""
msg = f"Unknown source {source_type}. " \
f"Valid options are: {self.type_of_source}"
assert source_type in self.type_of_source, msg
msg = f"Unknown output units {output_units}. " \
f"Valid options are: {tuple(REGISTERED_CONVERTERS.keys())}"
assert output_units in REGISTERED_CONVERTERS, msg
self.source_type = source_type
self.x = x
self.x_internal = None
self.power_density = 0
self.options = {}
self.output_units = output_units
self.concentration = concentration
self.options.update(kwargs)
self._spectrum = None
self._update_get_spectrum(self.output_units)
self._update_spectrum_function()
self.ready = False
self.cache_spectrum = None
[docs] def spectrum(self, x=None, output_units=None, concentration=None, **kwargs):
""" Returns the spectrum of the light in the requested units. Internally,
the spectrum is always managed in power density per nanometers, but the
output can be provided in other compatible units, such as power density per
Hertz or photon flux per eV.
:param x: (Default=None) If "x" is provided, it must be an array with the
spectral range in which to calculate the spectrum. Depending on the "units"
defined when creating the light source, this array must be in nm, m, eV,
J or hz.
:param output_units: Units of the output spectrum
:param concentration: Concentration of the light source
:param kwargs: Options to update the light source. It can be "units",
"concentration" or any of the options specific to the chosen type of light
source.
:return: Array with the spectrum in the requested units
"""
if x is None:
x = self.x if self.x is not None else self.x_internal
output_units = output_units if output_units is not None else self.output_units
con = concentration if concentration is not None else self.concentration
self._update_get_spectrum(output_units)
if kwargs:
self._update(**kwargs)
self._update_spectrum_function()
return self.x, self._get_spectrum(self._spectrum, x) * con
def _update(self, **kwargs):
""" Updates the options of the light source with new values.
It only updates existing options. No new options are added.
:param kwargs: A dictionary with the options to update and their new values.
:return: None
"""
for opt in kwargs:
if opt in self.options:
self.options[opt] = kwargs[opt]
self.ready = False
self.cache_spectrum = None
def _update_get_spectrum(self, output_units):
""" Updates the function to get the spectrum, depending on the chosen output units.
:return: None
"""
msg = f"Valid units are: {list(REGISTERED_CONVERTERS.keys())}."
assert output_units in REGISTERED_CONVERTERS, msg
self._get_spectrum = REGISTERED_CONVERTERS[output_units]
def _update_spectrum_function(self):
""" Updates the spectrum function during the light source creation or just after updating one or more of the options. It also updates the "options" property with any default options available to the chosen light source, if any.
:return: True
"""
try:
if self.source_type == "standard":
self._spectrum = self._get_standard_spectrum(self.options)
elif self.source_type == "laser":
self._spectrum = self._get_laser_spectrum(self.options)
elif self.source_type == "black body":
self._spectrum = self._get_blackbody_spectrum(self.options)
elif self.source_type == "SPECTRAL2":
self._spectrum = self._get_spectral2_spectrum(self.options)
elif self.source_type == "SMARTS":
self._spectrum = self._get_smarts_spectrum(self.options)
elif self.source_type == "custom":
self._spectrum = self._get_custom_spectrum(self.options)
else:
raise ValueError(
"Unknown type of light source: {0}.\nValid light sources are: {1}".format(
self.source_type, self.type_of_source
)
)
self.ready = True
except ValueError as err:
print(err)
def _get_standard_spectrum(self, options):
""" Gets one of the reference standard spectra: AM0, AM1.5g or AM1.5d.
:param options: A dictionary that contains the 'version' of the standard spectrum: 'AM0', 'AM1.5g' or 'AM1.5d'
:return: A function that takes as input the wavelengths and return the standard spectrum at those wavelengths.
"""
try:
version = options["version"]
spectra = reference_spectra()
wl = spectra[:, 0]
if version == "AM0":
spectrum = spectra[:, 1]
elif version == "AM1.5g":
spectrum = spectra[:, 2]
elif version == "AM1.5d":
spectrum = spectra[:, 3]
else:
raise KeyError(
'ERROR when creating a standard light source. Input parameters must include "version" '
'which can be equal to "AM0", "AM1.5g" or "AM1.5d" only.'
)
self.x_internal = wl
self.power_density = (
np.trapz(y=spectrum, x=wl) * self.concentration
)
output = interp1d(
x=wl, y=spectrum, bounds_error=False, fill_value=0, assume_sorted=True
)
return output
except KeyError as err:
print(err)
def _get_laser_spectrum(self, options):
""" Creates a gaussian light source with a given total power, linewidth and central wavelength. These three parameters must be provided in the "options" diccionary.
:param options: A dictionary that must contain the 'power', the 'linewidth' and the 'center' of the laser emission.
:return: A function that takes as input the wavelengths and return the laser spectrum at those wavelengths.
"""
try:
power = options["power"]
sigma2 = options["linewidth"] ** 2
center = options["center"]
def output(x):
out = (
power
/ np.sqrt(2 * np.pi * sigma2)
* np.exp(-(x - center) ** 2 / 2 / sigma2)
)
return out
self.x_internal = np.arange(
center - 5 * options["linewidth"],
center + 5 * options["linewidth"],
options["linewidth"] / 20,
)
self.power_density = power * self.concentration
return output
except KeyError:
print(
'ERROR when creating a laser light source. Input parameters must include "power", "linewidth"'
' and "center".'
)
def _get_blackbody_spectrum(self, options):
""" Gets the expontaneous emission in W/m2/sr/nm from a black body source chemical potential = 0
:param options: A dictionary that must contain the temperature of the blackbody, in kelvin 'T' and the 'entendue' in sr. If not provided, the entendue will be taken as 1 sr. Possible values for entendue are:
- 'Sun': The entendue will be taken as 6.8e-5 sr.
- 'hemispheric': The entendue wil be taken as pi/2 sr.
- A numeric value
:return: A function that takes as input the wavelengths and return the black body spectrum at those wavelengths.
"""
try:
T = options["T"]
if "entendue" in options:
if options["entendue"] == "Sun":
entendue = 6.8e-5
elif options["entendue"] == "hemispheric":
entendue = np.pi / 2
else:
entendue = options["entendue"]
else:
entendue = 1
options["entendue"] = 1
def BB(x):
x = x * 1e-9
out = (
2
* entendue
* h
* c ** 2
/ x ** 5
/ (np.exp(h * c / (x * kb * T)) - 1)
)
return out * 1e-9
wl_max = 2.897_772_9e6 / T
self.x_internal = np.arange(0, wl_max * 10, wl_max / 100)
sigma = 5.670_367e-8
self.power_density = (
sigma * T ** 4 * entendue / np.pi * self.concentration
)
return BB
except KeyError:
print(
'ERROR when creating a blackbody light source. Input parameters must include "T" and, '
'optionally, an "entendue", whose values can be "Sun" "hemispheric" or a number. '
"Equal to 1 if omitted."
)
def _get_spectral2_spectrum(self, options):
""" Get the solar spectrum calculated with the SPECTRAL2 calculator. The options dictionary is updated with the default options of all parameters if they are not provided.
:param options: A dictionary that contain all the options for the calculator.
:return: A function that takes as input the wavelengths and return the SPECTRAL2 calculated spectrum at those wavelengths.
"""
default = get_default_spectral2_object()
for opt in options:
if opt in default:
default[opt] = options[opt]
options.update(default)
wl, irradiance = calculate_spectrum_spectral2(options, power_density_in_nm=True)
self.x_internal = wl
self.power_density = (
np.trapz(y=irradiance, x=wl) * self.concentration
)
output = interp1d(
x=wl, y=irradiance, bounds_error=False, fill_value=0, assume_sorted=True
)
return output
def _get_smarts_spectrum(self, options):
""" Get the solar spectrum calculated with the SMARTS calculator. The options dictionary is updated with the default options of all parameters if they are not provided.
:param options: A dictionary that contain all the options for the calculator.
:return: A function that takes as input the wavelengths and return the SMARTS calculated spectrum at those wavelengths.
"""
outputs = {
"Extraterrestial": 2,
"True direct": 2,
"Experimental direct": 3,
"Global horizontal": 4,
"Global tilted": 5,
}
default = get_default_smarts_object()
for opt in options:
if opt in default:
default[opt] = options[opt]
options.update(default)
if "output" not in options:
options["output"] = "Global horizontal"
try:
output = outputs[options["output"]]
out = calculate_spectrum_smarts(options)
self.x_internal = out[0]
self.power_density = (
np.trapz(y=out[output], x=out[0]) * self.concentration
)
output = interp1d(
x=out[0],
y=out[output],
bounds_error=False,
fill_value=0,
assume_sorted=True,
)
return output
except KeyError:
print(
"ERROR: Output option no recognized. Avaliable options are: {}".format(
outputs
)
)
except RuntimeError as err:
print("ERROR in SMARTS: {}".format(err))
def _get_custom_spectrum(self, options):
""" Convert an custom spectrum into a solcore light source object.
:param options: A dictionary that contains the following information:
- 'x_data' and 'y_data' of the custom spectrum.
- 'input_units', the units of the spectrum, such as 'photon_flux_per_nm' or 'power_density_per_eV'
:return: A function that takes as input the wavelengths and return the custom spectrum at those wavelengths.
"""
try:
x_data = options["x_data"]
y_data = options["y_data"]
units = options["input_units"]
# We check the units type by type.
# Regardless of the input, we want power_density_per_nm
if units == "power_density_per_nm":
wl = x_data
spectrum = y_data
elif units == "photon_flux_per_nm":
wl = x_data
spectrum = y_data * (c * h * 1e9 / wl)
elif units == "power_density_per_m":
wl = x_data * 1e9
spectrum = y_data * 1e-9
elif units == "photon_flux_per_m":
wl = x_data * 1e9
spectrum = y_data * (c * h / wl)
elif units == "power_density_per_eV":
wl, spectrum = spectral_conversion_nm_ev(x_data, y_data)
elif units == "photon_flux_per_eV":
wl, spectrum = spectral_conversion_nm_ev(x_data, y_data)
spectrum = spectrum * (c * h * 1e9 / wl)
elif units == "power_density_per_J":
wl, spectrum = spectral_conversion_nm_ev(x_data / q, y_data * q)
elif units == "photon_flux_per_J":
wl, spectrum = spectral_conversion_nm_ev(x_data / q, y_data * q)
spectrum = spectrum * (c * h * 1e9 / wl)
elif units == "power_density_per_hz":
wl, spectrum = spectral_conversion_nm_hz(x_data, y_data)
elif units == "photon_flux_per_hz":
wl, spectrum = spectral_conversion_nm_hz(x_data, y_data)
spectrum = spectrum * (h * x_data)
else:
raise ValueError(
"Unknown units: {0}.\nValid units are: {1}.".format(
units, list(REGISTERED_CONVERTERS.keys())
)
)
self.x_internal = wl
self.power_density = (
np.trapz(y=spectrum, x=wl) * self.concentration
)
output = interp1d(
x=wl, y=spectrum, bounds_error=False, fill_value=0, assume_sorted=True
)
return output
except KeyError as err:
print(err)
[docs]def register_conversion_function(fun: Callable):
"""Registers a view method that will trigger an event. """
@wraps(fun)
def wrapper(*args, **kwargs):
return fun(*args, **kwargs)
REGISTERED_CONVERTERS[fun.__name__] = wrapper
return wrapper
[docs]@register_conversion_function
def power_density_per_nm(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in power density per nanometer.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the wavelengths (in nm)
:return: The spectrum in the chosen units.
"""
return spectrum(x)
[docs]@register_conversion_function
def photon_flux_per_nm(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in photon flux per nanometer.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the wavelengths (in nm)
:return: The spectrum in the chosen units.
"""
return spectrum(x) / (c * h * 1e9 / x)
[docs]@register_conversion_function
def power_density_per_m(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in power density per meter.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the wavelengths (in m)
:return: The spectrum in the chosen units.
"""
return spectrum(x * 1e9) * 1e9
[docs]@register_conversion_function
def photon_flux_per_m(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in photon flux per meter.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the wavelengths (in m)
:return: The spectrum in the chosen units.
"""
return spectrum(x * 1e9) / (c * h / x) * 1e9
[docs]@register_conversion_function
def power_density_per_ev(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in power density per eV.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the energies (in eV)
:return: The spectrum in the chosen units.
"""
wavelength = eVnm(x)[::-1]
output = spectrum(wavelength)
_, output = spectral_conversion_nm_ev(wavelength, output)
return output
[docs]@register_conversion_function
def photon_flux_per_ev(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in photon flux per eV.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the energies (in eV)
:return: The spectrum in the chosen units.
"""
wavelength = eVnm(x)[::-1]
output = spectrum(wavelength)
_, output = spectral_conversion_nm_ev(wavelength, output)
return output / (q * x)
[docs]@register_conversion_function
def power_density_per_joule(
spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray
):
""" Function that returns the spectrum in power density per Joule.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the energies (in J)
:return: The spectrum in the chosen units.
"""
wavelength = nmJ(x)[::-1]
output = spectrum(wavelength)
_, output = spectral_conversion_nm_ev(wavelength, output)
return output / q
[docs]@register_conversion_function
def photon_flux_per_joule(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in photon flux per Joule.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the energies (in J)
:return: The spectrum in the chosen units.
"""
wavelength = nmJ(x)[::-1]
output = spectrum(wavelength)
_, output = spectral_conversion_nm_ev(wavelength, output)
return output / (q * x)
[docs]@register_conversion_function
def power_density_per_hz(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in power density per hertz.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the frequencies (in hz)
:return: The spectrum in the chosen units.
"""
wavelength = nmHz(x)[::-1]
output = spectrum(wavelength)
_, output = spectral_conversion_nm_hz(wavelength, output)
return output
[docs]@register_conversion_function
def photon_flux_per_hz(spectrum: Callable[[np.ndarray], np.ndarray], x: np.ndarray):
""" Function that returns the spectrum in photon flux per hertz.
The input spectrum is assumed to be in power density per nanometer.
:param spectrum: The spectrum to interpolate.
:param x: Array with the frequencies (in hz)
:return: The spectrum in the chosen units.
"""
wavelength = nmHz(x)[::-1]
output = spectrum(wavelength)
frequency, output = spectral_conversion_nm_hz(wavelength, output)
return output / (h * frequency)