aopera 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aopera/photometry.py ADDED
@@ -0,0 +1,219 @@
1
+ """
2
+ Photometric budget
3
+ """
4
+
5
+ import numpy as np
6
+ from aopera.readconfig import set_attribute, read_config_file, read_config_tiptop
7
+ from aopera.utils import rad2arcsec
8
+ import logging
9
+
10
+ CONSTANT = {'h':6.626e-34, 'c':2.998e8, 'kb':1.381e-23}
11
+
12
+ def black_body(wvl, temp):
13
+ """
14
+ Black body spectrum [W/m2/sr/m]
15
+
16
+ Parameters
17
+ ----------
18
+ wvl : float, np.array
19
+ Wavelengths [m] at which to compute the spectrum.
20
+ temp : float
21
+ Temperature [K] of the black body.
22
+ """
23
+ return 2*CONSTANT['h']*CONSTANT['c']**2/((np.exp(CONSTANT['h']*CONSTANT['c']/(wvl*CONSTANT['kb']*temp))-1.0)*wvl**5)
24
+
25
+
26
+ def black_body_sun(wvl):
27
+ """
28
+ Sun black body spectrum [W/m2/m] as seen from Earth distance.
29
+
30
+ Parameters
31
+ ----------
32
+ wvl : float, np.array
33
+ Wavelengths [m] at which to compute the spectrum.
34
+ """
35
+ temp = 5780 # surface temperature [Kelvin]
36
+ solid_angle = 6.8*1e-5 # Sun disk seen from Earth [steradian]
37
+ return black_body(wvl, temp) * solid_angle
38
+
39
+
40
+ def atmospheric_transmission(zenith_rad, t_zenith=0.8):
41
+ """Atmospheric transmission"""
42
+ return t_zenith ** (1/np.cos(zenith_rad))
43
+
44
+
45
+ def satellite_flux(wvl_min, wvl_max, phase_angle=65, albedo=0.15):
46
+ """
47
+ Number of photons per second per steradian per m² of satellite surface.
48
+ The received number of photons on a detector will be:
49
+ N = satellite_flux(...) * T_exposure * S_satellite * S_telescope / distance² * throughput
50
+ """
51
+ incidence = 2/(3*np.pi) * (np.sin(np.deg2rad(phase_angle)) + (np.pi - np.deg2rad(phase_angle)) * np.cos(np.deg2rad(phase_angle)))
52
+ wvl = np.linspace(wvl_min, wvl_max, 2000)
53
+ spectrum = black_body_sun(wvl) * wvl/(CONSTANT['h']*CONSTANT['c']) # [photon/s/wvl]
54
+ nb_ph_sun = np.trapezoid(spectrum, wvl)
55
+ sun_size_idl_correction_factor = 3.1
56
+ return nb_ph_sun * albedo * incidence / sun_size_idl_correction_factor
57
+
58
+
59
+ class Band:
60
+ # These values come from OOMAO [Conan and Correia]
61
+ # [central wvl, bandwidth, zeropoint]
62
+ BANDS = {
63
+ "V": [ 550e-9, 90e-9, 3.3e12],
64
+ "R": [ 640e-9, 150e-9, 4e12],
65
+ "I": [ 790e-9, 150e-9, 2.7e12],
66
+ "J": [1215e-9, 260e-9, 1.9e12],
67
+ "H": [1654e-9, 290e-9, 1.1e12]
68
+ }
69
+
70
+ def __init__(self, band):
71
+ self.band = band
72
+
73
+ @property
74
+ def band(self):
75
+ return self._band
76
+
77
+ @band.setter
78
+ def band(self, band):
79
+ if band not in Band.BANDS.keys():
80
+ raise KeyError('The required photometric band [%s] has not been implemented'%band)
81
+ self._band = band
82
+
83
+ @property
84
+ def wavelength(self):
85
+ """Central wavelength of the band [m]"""
86
+ return Band.BANDS[self.band][0]
87
+
88
+ @property
89
+ def bandwidth(self):
90
+ """Bandwidth [m]"""
91
+ return Band.BANDS[self.band][1]
92
+
93
+ @property
94
+ def zeropoint(self):
95
+ """Photon per m2 per second at magnitude 0"""
96
+ return Band.BANDS[self.band][2]/368.0 # This factor comes from OOMAO [Conan and Correia]
97
+
98
+ def photon2mag(self, nphot):
99
+ """Convert number of photons per m2 per second in magnitude"""
100
+ return -2.5*np.log10(nphot/self.zeropoint)
101
+
102
+ def mag2photon(self, mag):
103
+ """Convert magnitude to number of photons per m2 per second"""
104
+ return self.zeropoint * 10**(-mag/2.5)
105
+
106
+
107
+ class SourceScience:
108
+ def __init__(self, dictionary):
109
+ set_attribute(self, dictionary, 'source_science')
110
+
111
+ def __repr__(self):
112
+ s = 'aopera.SOURCE_SCIENCE\n'
113
+ s += '----------------------\n'
114
+ s += 'wvl : %u nm\n'%self.wvl_nm
115
+ s += 'zenith : %u deg\n'%self.zenith_deg
116
+ return s
117
+
118
+ @staticmethod
119
+ def from_file(filepath, category='source_science'):
120
+ return SourceScience(read_config_file(filepath, category))
121
+
122
+ @staticmethod
123
+ def from_file_tiptop(filepath):
124
+ return SourceScience(read_config_tiptop(filepath)[4])
125
+
126
+ @staticmethod
127
+ def from_oopao(src):
128
+ return SourceScience({'wvl_nm':src.wavelength*1e9,'zenith_deg':0})
129
+
130
+ @property
131
+ def wvl(self):
132
+ return self.wvl_nm * 1e-9
133
+
134
+ @wvl.setter
135
+ def wvl(self, value):
136
+ self.wvl_nm = value * 1e9
137
+
138
+ @property
139
+ def zenith_rad(self):
140
+ return self.zenith_deg * np.pi/180
141
+
142
+ @zenith_rad.setter
143
+ def zenith_rad(self, value):
144
+ self.zenith_deg = value * 180/np.pi
145
+
146
+ @property
147
+ def elevation_rad(self):
148
+ return np.pi/2 - self.zenith_rad
149
+
150
+ @property
151
+ def elevation_deg(self):
152
+ return 90 - self.zenith_deg
153
+
154
+
155
+
156
+ class SourceWFS:
157
+ def __init__(self, dictionary):
158
+ self.square = False
159
+ set_attribute(self, dictionary, 'source_wfs')
160
+
161
+ def __repr__(self):
162
+ s = 'aopera.SOURCE_WFS\n'
163
+ s += '------------------\n'
164
+ s += 'wvl : %u nm\n'%self.wvl_nm
165
+ s += 'flux : %.2g ph/m²/s\n'%self.flux
166
+ s += 'separation: %.3f arcsec\n'%self.separation
167
+ s += 'size : %.2f arcsec\n'%self.size
168
+ return s
169
+
170
+ @staticmethod
171
+ def from_file(filepath, category='source_wfs'):
172
+ return SourceWFS(read_config_file(filepath, category))
173
+
174
+ @staticmethod
175
+ def from_file_tiptop(filepath):
176
+ return SourceWFS(read_config_tiptop(filepath)[5])
177
+
178
+ @staticmethod
179
+ def from_oopao(src_wfs, src_sci):
180
+ r_wfs, a_wfs = src_wfs.coordinates
181
+ x_wfs = r_wfs * np.cos(a_wfs*np.pi/180)
182
+ y_wfs = r_wfs * np.sin(a_wfs*np.pi/180)
183
+ r_sci, a_sci = src_sci.coordinates
184
+ x_sci = r_sci * np.cos(a_sci*np.pi/180)
185
+ y_sci = r_sci * np.sin(a_sci*np.pi/180)
186
+ logging.warning('Conversion from OOPAO to aopera assumes a point-like WFS source.')
187
+ return SourceWFS({'wvl_nm':src_wfs.wavelength*1e9,
188
+ 'flux':src_wfs.nPhoton,
189
+ 'separation':np.sqrt((x_sci-x_wfs)**2+(y_sci-y_wfs)**2),
190
+ 'angle':np.arctan2(y_wfs-y_sci, x_wfs-x_sci)*180/np.pi,
191
+ 'size':0})
192
+
193
+ @property
194
+ def wvl(self):
195
+ return self.wvl_nm * 1e-9
196
+
197
+ @wvl.setter
198
+ def wvl(self, value):
199
+ self.wvl_nm = value * 1e9
200
+
201
+ @property
202
+ def separation_x(self):
203
+ return self.separation * np.cos(self.angle*np.pi/180)
204
+
205
+ @property
206
+ def separation_y(self):
207
+ return self.separation * np.sin(self.angle*np.pi/180)
208
+
209
+ def image(self, nx, tel_diameter, samp):
210
+ xx,yy = np.mgrid[0:nx,0:nx] - nx//2
211
+ pix_size_arcsec = rad2arcsec((self.wvl/tel_diameter) / samp)
212
+ Robj_pix = (self.size/2) / pix_size_arcsec
213
+ if self.square:
214
+ obj = (np.abs(xx)<=Robj_pix)*(np.abs(yy)<=Robj_pix)
215
+ else:
216
+ rr = np.sqrt(xx**2+yy**2)
217
+ obj = (rr <= Robj_pix)
218
+ return obj / np.sum(obj)
219
+
aopera/readconfig.py ADDED
@@ -0,0 +1,267 @@
1
+ """
2
+ Load data from a .INI configuration file
3
+ """
4
+
5
+ import os
6
+ from configparser import ConfigParser
7
+ import logging
8
+ import numpy as np
9
+ # from aopera.turbulence import WVL_REF_SEEING
10
+ from aopera.utils import arcsec2rad
11
+
12
+ WVL_REF_SEEING = 500e-9 # TODO: solve circular import issue
13
+
14
+ def np_array(x):
15
+ try:
16
+ return np.array(eval(x))
17
+ except:
18
+ return np.array(x)
19
+
20
+ def make_bool(x):
21
+ if type(x) is bool:
22
+ return x
23
+ else:
24
+ return x.lower()=='true'
25
+
26
+ def float_or_none(x):
27
+ if (x is None) or (x == 'None'):
28
+ return None
29
+ else:
30
+ return float(x)
31
+
32
+ # INFO have following structure:
33
+ # key : (string to type converter, mandatory boolean, default value if optional)
34
+
35
+ INFO_ATMO_ABSTRACT = {'lext':(float,True,None), # [m]
36
+ 'altitude':(np_array,True,None), # [m]
37
+ 'wind_speed':(np_array,True,None), # [m/s]
38
+ 'wind_direction':(np_array,True,None)} # [deg]
39
+
40
+ INFO_ATMO_SEEING = dict(INFO_ATMO_ABSTRACT)
41
+ INFO_ATMO_SEEING.update({'seeing':(float,True,None), # [arcsec]
42
+ 'cn2dh_ratio':(np_array,True,None)}) # [no unit]
43
+
44
+ INFO_ATMO_CN2DH = dict(INFO_ATMO_ABSTRACT)
45
+ INFO_ATMO_CN2DH.update({'cn2dh':(np_array,True,None)}) # [m^(-1/3)]
46
+
47
+ INFO_SOURCE_SCIENCE = {'wvl_nm':(float,True,None), # [nm]
48
+ 'zenith_deg':(float,True,None)} # [deg]
49
+
50
+ INFO_SOURCE_WFS = {'wvl_nm':(float,True,None), # [nm]
51
+ 'flux':(float,True,None), # [ph/m²/s]
52
+ 'separation':(float,True,None), # [arcsec]
53
+ 'angle':(float,True,None), # [deg]
54
+ 'size':(float,True,None)} # [arcsec]
55
+
56
+ INFO_PUPIL = {'diameter':(float,True,None), # [m]
57
+ 'occultation':(float,True,None), # [no unit]
58
+ 'nact':(int,True,None), # [no unit]
59
+ 'ncpa':(float,False,0), # [nm RMS]
60
+ 'nmode_ratio':(float,False,1.0)}
61
+
62
+ INFO_RTC = {'freq':(float,True,None), # [Hz]
63
+ 'delay':(float,True,None), # [ms]
64
+ 'ki':(float,True,None)} # [no unit]
65
+
66
+ INFO_WFS_ABSTRACT = {'lenslet':(int,True,None), # [no unit]
67
+ 'ron':(float,True,None), # [e-/pixel]
68
+ 'emccd':(make_bool,True,None)} # [no unit]
69
+
70
+ INFO_PWFS = dict(INFO_WFS_ABSTRACT)
71
+ INFO_PWFS.update({'modulation':(float,True,None),
72
+ 'og_compensation':(bool,False,False)}) # [lambda/D]
73
+
74
+ INFO_SHWFS = dict(INFO_WFS_ABSTRACT)
75
+ INFO_SHWFS.update({'samp':(float,True,None), # [no unit]
76
+ 'npix_cog':(int,True,None),
77
+ 'weight':(float_or_none,False,None)}) # [no unit]
78
+
79
+
80
+ INFO = {'atmosphere_seeing':INFO_ATMO_SEEING,
81
+ 'atmosphere_cn2dh':INFO_ATMO_CN2DH,
82
+ 'source_science':INFO_SOURCE_SCIENCE,
83
+ 'source_wfs':INFO_SOURCE_WFS,
84
+ 'pupil':INFO_PUPIL,
85
+ 'rtc':INFO_RTC,
86
+ 'pwfs':INFO_PWFS,
87
+ 'shwfs':INFO_SHWFS}
88
+
89
+
90
+ def read_config_file(filepath, category):
91
+ """Generic function to read a specific category of a INI file"""
92
+ cfg = ConfigParser()
93
+ cfg.optionxform = str
94
+ out = cfg.read(filepath)
95
+ if len(out)==0:
96
+ # Try in the 'data' folder
97
+ filepath_data = os.path.dirname(os.path.abspath(__file__)) + os.path.sep + 'data' + os.path.sep + filepath
98
+ out = cfg.read(filepath_data)
99
+ if len(out)==0:
100
+ raise FileNotFoundError("The configuration file has not been found")
101
+ else:
102
+ logging.info('File <%s> has been found in <aopera/data/>'%filepath)
103
+ if not category in cfg.keys():
104
+ raise ValueError("The category [%s] does not appear in the configuration file"%category)
105
+ return cfg[category]
106
+
107
+
108
+ def read_config_tiptop(filepath):
109
+ """
110
+ Read a TIPTOP INI file to generate aopera compatible dictionaries
111
+ """
112
+ cfg = ConfigParser()
113
+ cfg.optionxform = str
114
+ out = cfg.read(filepath)
115
+ if len(out)==0:
116
+ raise FileNotFoundError("The configuration file has not been found")
117
+
118
+ cfg_tel = cfg['telescope']
119
+ cfg_atm = cfg['atmosphere']
120
+ dm_act = eval(cfg['DM']['NumberActuators'])
121
+
122
+ if len(dm_act)>1: #TODO: SCAO only
123
+ raise ValueError('TIPTOP file has more than one DM, this is not compatible with aopera')
124
+
125
+ if float(cfg_atm['Wavelength']) != WVL_REF_SEEING:
126
+ raise ValueError('TIPTOP file must define seeing at %u nm'%(WVL_REF_SEEING*1e9))
127
+
128
+ if 'SensorFrameRate_LO' in cfg['RTC'].keys():
129
+ raise ValueError('Cannot use a Low Order split with aopera')
130
+
131
+ area_shape = cfg['DM']['AoArea'].replace('\'','')
132
+ if area_shape == 'circle':
133
+ nmode_ratio = np.pi/4
134
+ elif area_shape == 'square':
135
+ nmode_ratio = 1
136
+ else:
137
+ raise ValueError('Error when reading Tiptop file, the DM AoArea must be `circle` or `square`')
138
+
139
+ pupil = {'diameter':float(cfg_tel['TelescopeDiameter']),
140
+ 'occultation':float(cfg_tel['ObscurationRatio']),
141
+ 'nact':dm_act[0],
142
+ 'nmode_ratio':nmode_ratio}
143
+
144
+ atmosphere = {'seeing':float(cfg_atm['Seeing']),
145
+ 'cn2dh_ratio':np_array(cfg_atm['Cn2Weights']),
146
+ 'lext':float(cfg_atm['L0']),
147
+ 'altitude':np_array(cfg_atm['Cn2Heights']),
148
+ 'wind_speed':np_array(cfg_atm['WindSpeed']),
149
+ 'wind_direction':90 - np_array(cfg_atm['WindDirection'])}
150
+
151
+ nb_frame_delay = float(cfg['RTC']['LoopDelaySteps_HO']) - 1 # TIPTOP counts WFS integration as frame delay
152
+ freq = float(cfg['RTC']['SensorFrameRate_HO'])
153
+
154
+ rtc = {'freq':freq,
155
+ 'delay':nb_frame_delay/freq*1e3,
156
+ 'ki':float(cfg['RTC']['LoopGain_HO'])}
157
+
158
+ if eval(cfg['sources_science']['Zenith'])[0] != 0:
159
+ raise ValueError('aopera evaluates performance at center of array, source science zenith angle should be null.')
160
+
161
+ src_sci_wvl = eval(cfg['sources_science']['Wavelength'])
162
+ if len(src_sci_wvl) > 1:
163
+ raise NotImplementedError('No compatibility between TIPTOP and aopera for multiple science wavelengths.')
164
+ src_sci = {'wvl_nm':src_sci_wvl[0]*1e9,
165
+ 'zenith_deg':float(cfg_tel['ZenithAngle'])}
166
+
167
+ cfg_src_wfs = cfg['sources_HO']
168
+ cfg_wfs = cfg['sensor_HO']
169
+
170
+ wfs_type = cfg_wfs['WfsType']
171
+ if int(cfg_wfs['ExcessNoiseFactor']) == 1:
172
+ emccd = False
173
+ elif int(cfg_wfs['ExcessNoiseFactor']) == 2:
174
+ emccd = True
175
+ else:
176
+ raise ValueError('Can only process ExcessNoiseFactor with value 1 or 2')
177
+
178
+ wfs_nb_lenslet = eval(cfg_wfs['NumberLenslets'])
179
+ if len(wfs_nb_lenslet) > 1:
180
+ raise ValueError('TIPTOP file has more than one WFS, this is not compatible with aopera')
181
+
182
+ wfs = {'lenslet':wfs_nb_lenslet[0],
183
+ 'ron':float(cfg_wfs['SigmaRON']),
184
+ 'emccd':emccd}
185
+
186
+ nb_phot_wfs = eval(cfg_wfs['NumberPhotons'])[0] # nb photon/subap/frame
187
+ wfs_flux = nb_phot_wfs * rtc['freq'] * wfs['lenslet']**2 / pupil['diameter']**2
188
+
189
+
190
+ src_wfs = {'wvl_nm':float(cfg_src_wfs['Wavelength'])*1e9,
191
+ 'flux':wfs_flux,
192
+ 'separation':eval(cfg_src_wfs['Zenith'])[0],
193
+ 'angle':eval(cfg_src_wfs['Azimuth'])[0],
194
+ 'size':0}
195
+
196
+ if 'Pyramid' in wfs_type:
197
+ wfs['modulation'] = float(cfg_wfs['Modulation'])
198
+ elif 'Shack-Hartmann' in wfs_type:
199
+ logging.warning('TIPTOP and aopera definitions for SH spot CoG algorithm have to be checked.')
200
+ pix_rad = arcsec2rad(float(cfg_wfs['PixelScale'])*1e-3)
201
+ lmbd_dpup = src_wfs['wvl_nm'] * 1e-9 / (pupil['diameter']/wfs['lenslet'])
202
+ wfs['samp'] = lmbd_dpup / pix_rad
203
+ wfs['npix_cog'] = 10
204
+ else:
205
+ raise ValueError('Unknown TIPTOP WFS type')
206
+
207
+ return pupil, atmosphere, wfs, rtc, src_sci, src_wfs
208
+
209
+
210
+ def read_config_tiptop_samp(filepath):
211
+ """Read a TIPTOP INI file and compute PSF sampling at WFS wavelength"""
212
+ cfg = ConfigParser()
213
+ cfg.optionxform = str
214
+ out = cfg.read(filepath)
215
+ if len(out)==0:
216
+ raise FileNotFoundError("The configuration file has not been found")
217
+ diameter = float(cfg['telescope']['TelescopeDiameter'])
218
+ wvl_wfs = float(cfg['sources_HO']['Wavelength'])
219
+ pix_mas = float(cfg['sensor_science']['PixelScale'])
220
+ return (wvl_wfs/diameter) / arcsec2rad(pix_mas*1e-3)
221
+
222
+
223
+ def read_config_tiptop_nx(filepath):
224
+ """Read a TIPTOP INI file and compute PSF sampling at WFS wavelength"""
225
+ cfg = ConfigParser()
226
+ cfg.optionxform = str
227
+ out = cfg.read(filepath)
228
+ if len(out)==0:
229
+ raise FileNotFoundError("The configuration file has not been found")
230
+ return int(cfg['sensor_science']['FieldOfView'])
231
+
232
+
233
+ def check_dictionary(dictionary, category_name):
234
+ """
235
+ Check keywords.
236
+ Return a dictionary with optional keywords filled with default value.
237
+ """
238
+ for k in INFO[category_name].keys():
239
+ if not k in dictionary.keys():
240
+ if INFO[category_name][k][1]: # Keyword is mandatory
241
+ logging.error('The keyword \'%s\' is mandatory in the category \'%s\''%(k,category_name))
242
+ else:
243
+ # logging.warning('The keyword \'%s\' has not been defined in the category \'%s\', it is set to default value \'%s\''%(k,category_name,INFO[category_name][k][2]))
244
+ logging.warning('Undefined keyword set to default value \'%s.%s=%s\''%(category_name,k,INFO[category_name][k][2]))
245
+ try:
246
+ dictionary[k] = INFO[category_name][k][2]
247
+ except: # config parser requires strings
248
+ dictionary[k] = str(INFO[category_name][k][2])
249
+ else:
250
+ try:
251
+ INFO[category_name][k][0](dictionary[k]) # try type conversion
252
+ except:
253
+ logging.error('The keyword \'%s\' in the category \'%s\' does not have the correct type'%(k,category_name))
254
+ for k in dictionary.keys():
255
+ if not k in INFO[category_name].keys():
256
+ logging.warning('The keyword \'%s\' is ignored by the category \'%s\''%(k,category_name))
257
+ return dictionary
258
+
259
+
260
+ def set_attribute(slf, dictionary, category_name):
261
+ """
262
+ Set a disctionary as attribute of the Python object `slf`
263
+ """
264
+ dictionary = check_dictionary(dictionary, category_name)
265
+ for k in INFO[category_name].keys():
266
+ setattr(slf, k, INFO[category_name][k][0](dictionary[k]))
267
+