PyDIET 0.9.3__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.
Files changed (177) hide show
  1. pydiet/__init__.py +12 -0
  2. pydiet/api_client/__init__.py +6 -0
  3. pydiet/api_client/client.py +57 -0
  4. pydiet/cmd/__init__.py +9 -0
  5. pydiet/cmd/start.py +107 -0
  6. pydiet/data/data_config.toml +242 -0
  7. pydiet/data/description.txt +1 -0
  8. pydiet/data/instruments/description.txt +1 -0
  9. pydiet/data/instruments/megacam/default +0 -0
  10. pydiet/data/instruments/megacam/description.txt +1 -0
  11. pydiet/data/instruments/megacam/detector/description.txt +1 -0
  12. pydiet/data/instruments/megacam/detector/qe/MegaCam_QE.average.fits +0 -0
  13. pydiet/data/instruments/megacam/detector/qe/description.txt +2 -0
  14. pydiet/data/instruments/megacam/filters/CaHK.MP9303.fits +0 -0
  15. pydiet/data/instruments/megacam/filters/Ha.MP9603.fits +0 -0
  16. pydiet/data/instruments/megacam/filters/HaOFF.MP9604.fits +0 -0
  17. pydiet/data/instruments/megacam/filters/M4112.MP9403.fits +0 -0
  18. pydiet/data/instruments/megacam/filters/M4376.MP9404.fits +0 -0
  19. pydiet/data/instruments/megacam/filters/OIII.MP9501.fits +0 -0
  20. pydiet/data/instruments/megacam/filters/OIIIOFF.MP9502.fits +0 -0
  21. pydiet/data/instruments/megacam/filters/description.txt +1 -0
  22. pydiet/data/instruments/megacam/filters/g.MP9402.fits +0 -0
  23. pydiet/data/instruments/megacam/filters/gri.MP9605.fits +0 -0
  24. pydiet/data/instruments/megacam/filters/i.MP9703.fits +0 -0
  25. pydiet/data/instruments/megacam/filters/r.MP9602.fits +0 -0
  26. pydiet/data/instruments/megacam/filters/u.MP9302.fits +0 -0
  27. pydiet/data/instruments/megacam/filters/z.MP9901.fits +0 -0
  28. pydiet/data/instruments/megacam/optics/description.txt +2 -0
  29. pydiet/data/instruments/megacam/optics/transmission/MegaPrime_transmission.fits +0 -0
  30. pydiet/data/instruments/megacam/optics/transmission/description.txt +2 -0
  31. pydiet/data/instruments/wircam/description.txt +1 -0
  32. pydiet/data/instruments/wircam/detector/description.txt +1 -0
  33. pydiet/data/instruments/wircam/detector/qe/WIRCam_QE.average.fits +0 -0
  34. pydiet/data/instruments/wircam/detector/qe/description.txt +2 -0
  35. pydiet/data/instruments/wircam/filters/BrG.WC8305.fits +0 -0
  36. pydiet/data/instruments/wircam/filters/CH4Off.WC8204.fits +0 -0
  37. pydiet/data/instruments/wircam/filters/CH4On.WC8203.fits +0 -0
  38. pydiet/data/instruments/wircam/filters/CO.WC8306.fits +0 -0
  39. pydiet/data/instruments/wircam/filters/H.WC8201.fits +0 -0
  40. pydiet/data/instruments/wircam/filters/H.WC8202.fits +0 -0
  41. pydiet/data/instruments/wircam/filters/H2.WC8304.fits +0 -0
  42. pydiet/data/instruments/wircam/filters/J.WC8101.fits +0 -0
  43. pydiet/data/instruments/wircam/filters/J.WC8103.fits +0 -0
  44. pydiet/data/instruments/wircam/filters/Kcont.WC8303.fits +0 -0
  45. pydiet/data/instruments/wircam/filters/Ks.WC8301.fits +0 -0
  46. pydiet/data/instruments/wircam/filters/Ks.WC8302.fits +0 -0
  47. pydiet/data/instruments/wircam/filters/LowOH1.WC8104.fits +0 -0
  48. pydiet/data/instruments/wircam/filters/LowOH2.WC8102.fits +0 -0
  49. pydiet/data/instruments/wircam/filters/W.WC8105.fits +0 -0
  50. pydiet/data/instruments/wircam/filters/Y.WC8002.fits +0 -0
  51. pydiet/data/instruments/wircam/filters/description.txt +1 -0
  52. pydiet/data/instruments/wircam/optics/description.txt +2 -0
  53. pydiet/data/instruments/wircam/optics/transmission/WIRCam_transmission.fits +0 -0
  54. pydiet/data/instruments/wircam/optics/transmission/description.txt +2 -0
  55. pydiet/data/sites/description.txt +1 -0
  56. pydiet/data/sites/mko/default +0 -0
  57. pydiet/data/sites/mko/description.txt +2 -0
  58. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.0.fits +0 -0
  59. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.1.fits +0 -0
  60. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.2.fits +0 -0
  61. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.3.fits +0 -0
  62. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.4.fits +0 -0
  63. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.5.fits +0 -0
  64. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.6.fits +0 -0
  65. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.7.fits +0 -0
  66. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.8.fits +0 -0
  67. pydiet/data/sites/mko/emission/MKO_emission.bright.AM1.9.fits +0 -0
  68. pydiet/data/sites/mko/emission/MKO_emission.bright.AM2.0.fits +0 -0
  69. pydiet/data/sites/mko/emission/MKO_emission.bright.AM2.5.fits +0 -0
  70. pydiet/data/sites/mko/emission/MKO_emission.bright.AM3.0.fits +0 -0
  71. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.0.fits +0 -0
  72. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.1.fits +0 -0
  73. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.2.fits +0 -0
  74. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.3.fits +0 -0
  75. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.4.fits +0 -0
  76. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.5.fits +0 -0
  77. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.6.fits +0 -0
  78. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.7.fits +0 -0
  79. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.8.fits +0 -0
  80. pydiet/data/sites/mko/emission/MKO_emission.dark.AM1.9.fits +0 -0
  81. pydiet/data/sites/mko/emission/MKO_emission.dark.AM2.0.fits +0 -0
  82. pydiet/data/sites/mko/emission/MKO_emission.dark.AM2.5.fits +0 -0
  83. pydiet/data/sites/mko/emission/MKO_emission.dark.AM3.0.fits +0 -0
  84. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.0.fits +0 -0
  85. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.1.fits +0 -0
  86. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.2.fits +0 -0
  87. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.3.fits +0 -0
  88. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.4.fits +0 -0
  89. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.5.fits +0 -0
  90. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.6.fits +0 -0
  91. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.7.fits +0 -0
  92. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.8.fits +0 -0
  93. pydiet/data/sites/mko/emission/MKO_emission.grey.AM1.9.fits +0 -0
  94. pydiet/data/sites/mko/emission/MKO_emission.grey.AM2.0.fits +0 -0
  95. pydiet/data/sites/mko/emission/MKO_emission.grey.AM2.5.fits +0 -0
  96. pydiet/data/sites/mko/emission/MKO_emission.grey.AM3.0.fits +0 -0
  97. pydiet/data/sites/mko/emission/description.txt +5 -0
  98. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.0.fits +0 -0
  99. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.1.fits +0 -0
  100. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.2.fits +0 -0
  101. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.3.fits +0 -0
  102. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.4.fits +0 -0
  103. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.5.fits +0 -0
  104. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.6.fits +0 -0
  105. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.7.fits +0 -0
  106. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.8.fits +0 -0
  107. pydiet/data/sites/mko/transmission/MKO_transmission.AM1.9.fits +0 -0
  108. pydiet/data/sites/mko/transmission/MKO_transmission.AM2.0.fits +0 -0
  109. pydiet/data/sites/mko/transmission/MKO_transmission.AM2.5.fits +0 -0
  110. pydiet/data/sites/mko/transmission/MKO_transmission.AM3.0.fits +0 -0
  111. pydiet/data/sites/mko/transmission/MKO_transmission.AM3.5.fits +0 -0
  112. pydiet/data/sites/mko/transmission/MKO_transmission.AM4.0.fits +0 -0
  113. pydiet/data/sites/mko/transmission/MKO_transmission.AM4.5.fits +0 -0
  114. pydiet/data/sites/mko/transmission/MKO_transmission.AM5.0.fits +0 -0
  115. pydiet/data/sites/mko/transmission/description.txt +5 -0
  116. pydiet/data/telescopes/cfht/default +0 -0
  117. pydiet/data/telescopes/cfht/description.txt +1 -0
  118. pydiet/data/telescopes/cfht/emission/description.txt +2 -0
  119. pydiet/data/telescopes/cfht/transmission/CFHT_M1_transmission.fits +0 -0
  120. pydiet/data/telescopes/cfht/transmission/description.txt +1 -0
  121. pydiet/data/telescopes/description.txt +1 -0
  122. pydiet/package.py +55 -0
  123. pydiet/py.typed +0 -0
  124. pydiet/server/__init__.py +9 -0
  125. pydiet/server/app.py +369 -0
  126. pydiet/server/config/__init__.py +51 -0
  127. pydiet/server/config/config.py +330 -0
  128. pydiet/server/config/fields.py +49 -0
  129. pydiet/server/config/settings.py +166 -0
  130. pydiet/server/data.py +31 -0
  131. pydiet/server/datafiles.py +367 -0
  132. pydiet/server/image.py +342 -0
  133. pydiet/server/models/__init__.py +34 -0
  134. pydiet/server/models/dataconfig.py +195 -0
  135. pydiet/server/models/default.py +9 -0
  136. pydiet/server/models/exceptions.py +9 -0
  137. pydiet/server/models/instrument.py +314 -0
  138. pydiet/server/models/query.py +172 -0
  139. pydiet/server/models/response.py +97 -0
  140. pydiet/server/models/types.py +35 -0
  141. pydiet/server/photsys.py +71 -0
  142. pydiet/server/response.py +237 -0
  143. pydiet/server/types/__init__.py +8 -0
  144. pydiet/server/types/quantity.py +532 -0
  145. pydiet/server/types/string.py +318 -0
  146. pydiet/templates/common/base.html +80 -0
  147. pydiet/templates/common/plot_filter.html +17 -0
  148. pydiet/templates/common/privacy.html +132 -0
  149. pydiet/templates/common/settings.html +23 -0
  150. pydiet/templates/common/terms.html +101 -0
  151. pydiet/templates/megacam/etc_form.html +319 -0
  152. pydiet/templates/megacam/etc_results.html +190 -0
  153. pydiet/templates/wircam/etc_form.html +319 -0
  154. pydiet/templates/wircam/etc_results.html +190 -0
  155. pydiet/web_client/css/style.css +221 -0
  156. pydiet/web_client/dist/pydiet.js +31 -0
  157. pydiet/web_client/images/logo.svg +6 -0
  158. pydiet/web_client/images/megacam/background.jpg +0 -0
  159. pydiet/web_client/images/megacam/logo.png +0 -0
  160. pydiet/web_client/images/wircam/background.jpg +0 -0
  161. pydiet/web_client/images/wircam/logo.png +0 -0
  162. pydiet/web_client/js/dom.js +51 -0
  163. pydiet/web_client/js/etc.js +63 -0
  164. pydiet/web_client/js/fetch.js +49 -0
  165. pydiet/web_client/js/instrument.js +62 -0
  166. pydiet/web_client/js/main.js +15 -0
  167. pydiet/web_client/js/plot.js +88 -0
  168. pydiet/web_client/js/settings.js +57 -0
  169. pydiet/web_client/js/theme.js +43 -0
  170. pydiet/web_client/js/url.js +12 -0
  171. pydiet/web_client/jsdoc.json +20 -0
  172. pydiet/web_client/package.json +83 -0
  173. pydiet-0.9.3.dist-info/METADATA +118 -0
  174. pydiet-0.9.3.dist-info/RECORD +177 -0
  175. pydiet-0.9.3.dist-info/WHEEL +4 -0
  176. pydiet-0.9.3.dist-info/entry_points.txt +5 -0
  177. pydiet-0.9.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,367 @@
1
+ """
2
+ Functions that gather data from files.
3
+ """
4
+ # Copyright CFHT/CNRS/CEA/UParisSaclay
5
+ # Licensed under the MIT licence
6
+
7
+ from os import PathLike, scandir
8
+ from os.path import basename, exists, isabs, join
9
+ from pathlib import Path
10
+
11
+ # Manage TOML library for Python versions < 3.11
12
+ import sys
13
+ if sys.version_info >= (3, 11):
14
+ import tomllib
15
+ else:
16
+ import tomli as tomllib
17
+ import warnings
18
+
19
+ from typing import Any, IO, Optional
20
+ from astropy.table import QTable #type: ignore[import-untyped]
21
+ from astropy import units as u #type: ignore[import-untyped]
22
+ from astropy.modeling.models import Const1D #type: ignore[import-untyped]
23
+ from astropy.utils.exceptions import AstropyUserWarning #type: ignore[import-untyped]
24
+ from pydantic import BaseModel, Field
25
+ from specutils import Spectrum #type: ignore[import-untyped]
26
+ from synphot import ( #type: ignore[import-untyped]
27
+ SourceSpectrum,
28
+ SpectralElement,
29
+ ThermalSpectralElement
30
+ ) #type: ignore[import-untyped]
31
+
32
+ from .. import package
33
+ from .config import override, settings
34
+ from .models.dataconfig import (
35
+ DataConfigModel,
36
+ DetectorConfigModel,
37
+ EmissionConfigModel,
38
+ FiltersConfigModel,
39
+ OpticsConfigModel,
40
+ TransmissionConfigModel
41
+ )
42
+ from .models.instrument import (
43
+ DetectorModel,
44
+ FiltersModel,
45
+ InstrumentModel,
46
+ OpticsModel,
47
+ SBSEDModel,
48
+ SiteModel,
49
+ TelescopeModel,
50
+ TransmissionModel
51
+ )
52
+
53
+ def add_trans(self, other):
54
+ """Add ``self`` with ``other``."""
55
+ self._validate_other_mul_div(other)
56
+ result = self.__class__(self.model + other.model)
57
+ self._merge_meta(self, other, result)
58
+ return result
59
+
60
+
61
+ SpectralElement.__add__ = add_trans
62
+
63
+
64
+ def get_data_config(data_config: Optional[str] = None) -> DataConfigModel:
65
+ data_config = override("data_config", data_config)
66
+ assert data_config is not None # This is to make mypy happy
67
+ with open(data_config, "rb") as f:
68
+ data = tomllib.load(f)
69
+ assert data is not None # This is to make mypy happy
70
+ # if "path" value is not absolute, assume it is relative to pkg root dir
71
+ if not isabs(data['path']):
72
+ data['path'] = join(package.root_dir, data['path'])
73
+ data_config_model = DataConfigModel.model_validate(data)
74
+ return data_config_model
75
+
76
+
77
+ def get_data_file(filename: IO[bytes] | PathLike | str):
78
+ return QTable.read(filename)
79
+
80
+
81
+ def get_default(d: dict) -> Any:
82
+ lst = [val for val in d.values() if val.default]
83
+ return lst[0] if len(lst) > 0 else list(d.values())[0]
84
+
85
+
86
+ def get_detector(
87
+ parent_dir: str,
88
+ detector: DetectorConfigModel) -> DetectorModel:
89
+ # Instantiate the model
90
+ transmissions = get_transmissions(
91
+ join(parent_dir, detector.path),
92
+ detector.transmission
93
+ )
94
+ emissions = get_emissions(
95
+ join(parent_dir, detector.path),
96
+ detector.emission
97
+ )
98
+ return DetectorModel(
99
+ gain = detector.gain,
100
+ ron = detector.ron,
101
+ scale = detector.scale,
102
+ transmissions = transmissions,
103
+ emissions = emissions
104
+ )
105
+
106
+
107
+ def get_emission(
108
+ file: str,
109
+ id: str,
110
+ name: str="",
111
+ description: str="",
112
+ vars: dict[str, float | str]={},
113
+ ) -> SBSEDModel:
114
+ data = get_data_file(file)
115
+ wave = u.Quantity(data['WAVELENGTH'])
116
+ sed = u.Quantity(data['PHOTLAM']).to(
117
+ u.Jy / u.arcsec**2,
118
+ equivalencies=u.spectral_density(wave)
119
+ )
120
+ # Instantiate the model
121
+ with warnings.catch_warnings():
122
+ warnings.filterwarnings(
123
+ "ignore",
124
+ message=".*negative flux or throughput.*",
125
+ category=AstropyUserWarning,
126
+ )
127
+ emission = SBSEDModel(
128
+ id = id,
129
+ name = name,
130
+ description = description,
131
+ vars = vars,
132
+ # We drop the surface part as Spectrum does cannot deal with SBs.
133
+ spectral = SourceSpectrum.from_spectrum1d(
134
+ Spectrum(
135
+ spectral_axis = wave,
136
+ flux = sed * u.arcsec**2
137
+ ),
138
+ keep_neg=False
139
+ )
140
+ )
141
+ return emission
142
+
143
+
144
+ def get_emission_from_transmission(
145
+ transmission: TransmissionModel,
146
+ temperature: u.Quantity['temperature'], #type: ignore[name-defined]
147
+ id: str) -> SBSEDModel:
148
+ flat = SpectralElement(Const1D, amplitude=1.)
149
+ assert transmission.spectral != None # Make mypy happy
150
+ emission = SBSEDModel(
151
+ id = id,
152
+ name = f"{transmission.name} emission",
153
+ description = f"Blackbody emission at {temperature.to(u.K).value:.1f} K",
154
+ # Thermal source spectral flux with Blackbody spectrum over 1 arcsec2
155
+ spectral = ThermalSpectralElement(
156
+ flat + (-1.) * transmission.spectral, # Apply emissivity
157
+ temperature=temperature,
158
+ beam_fill_factor=1.0
159
+ ).thermal_source()
160
+ )
161
+ return emission
162
+
163
+
164
+ """
165
+ def get_emission_from_transmission(
166
+ transmission: TransmissionModel,
167
+ temperature: u.Quantity['temperature'], #type: ignore[name-defined]
168
+ area: u.Quantity['area'], #type: ignore[name-defined]
169
+ id: str) -> SBSEDModel:
170
+ # Thermal source spectral flux with Blackbody spectrum over 1 arcsec2
171
+ bb = ThermalSpectralElement(
172
+ BlackBody1D,
173
+ temperature=temperature
174
+ ).thermal_source() * area.to(u.m**2).value
175
+ emission = SBSEDModel(
176
+ id = id,
177
+ name = f"{transmission.name} emission",
178
+ description = f"Blackbody emission at {temperature.to(u.K).value:.1f} K",
179
+ # Apply emissivity
180
+ spectral = bb - bb * transmission.spectral
181
+ )
182
+ return emission
183
+ """
184
+
185
+
186
+ def get_emissions(
187
+ parent_dir: str,
188
+ emission_config: EmissionConfigModel,
189
+ transmissions: dict[str, TransmissionModel] | None = None
190
+ ) -> dict[str, SBSEDModel]:
191
+ emissions : dict[str, SBSEDModel] = {}
192
+ for file_config in emission_config.files:
193
+ key = file_config.id if file_config.id != '' else str(len(emissions))
194
+ emissions[key] = get_emission(
195
+ file=join(parent_dir, emission_config.path, file_config.file),
196
+ id=key,
197
+ name=file_config.name,
198
+ description = file_config.description,
199
+ vars = file_config.vars
200
+ )
201
+ # No emission files: we use a blackbody with emissivity from transmission
202
+ if len(emission_config.files) == 0 and transmissions is not None:
203
+ temperatures = emission_config.temperatures
204
+ for t, key in enumerate(transmissions):
205
+ temperature = temperatures[t] if t < len(temperatures) \
206
+ else temperatures[-1]
207
+ emissions[key] = get_emission_from_transmission(
208
+ transmissions[key],
209
+ temperature=temperature,
210
+ id=key
211
+ )
212
+ return emissions
213
+
214
+
215
+ def get_filters(
216
+ parent_dir: str,
217
+ filters_config: FiltersConfigModel) -> FiltersModel:
218
+ path = join(parent_dir, filters_config.path)
219
+ transmissions = get_transmissions(path, filters_config.transmission)
220
+ # For emissions we may have to use transmission curves
221
+ emissions = get_emissions(
222
+ path,
223
+ filters_config.emission,
224
+ transmissions
225
+ )
226
+ return FiltersModel(
227
+ transmissions=transmissions,
228
+ emissions=emissions
229
+ )
230
+
231
+
232
+ def get_instruments(
233
+ data_config: DataConfigModel) -> dict:
234
+ # Start by gathering the provided sites and telescopes
235
+ sites = get_sites(data_config)
236
+ telescopes = get_telescopes(data_config)
237
+ instruments = {}
238
+ for instrument in data_config.instruments:
239
+ path = join(data_config.path, instrument.path)
240
+ # Instantiate the model
241
+ instruments[instrument.id] = InstrumentModel(
242
+ id = instrument.id,
243
+ name = instrument.name,
244
+ description = instrument.description,
245
+ wavelength_range = instrument.wavelength_range,
246
+ obstruction_area = instrument.obstruction_area,
247
+ overhead = instrument.overhead,
248
+ optics = get_optics(path, instrument.optics),
249
+ filters = get_filters(path, instrument.filters),
250
+ detector = get_detector(path, instrument.detector),
251
+ telescope = telescopes[instrument.telescope_id],
252
+ site = sites[instrument.site_id],
253
+ default = instrument.default
254
+ )
255
+ return instruments
256
+
257
+
258
+ def get_optics(
259
+ parent_dir: str,
260
+ optics_config: OpticsConfigModel) -> OpticsModel:
261
+ path = join(parent_dir, optics_config.path)
262
+ transmissions = get_transmissions(path, optics_config.transmission)
263
+ # For emissions we may have to use transmission curves
264
+ emissions = get_emissions(
265
+ path,
266
+ optics_config.emission,
267
+ transmissions
268
+ )
269
+ return OpticsModel(
270
+ transmissions=transmissions,
271
+ emissions=emissions
272
+ )
273
+
274
+
275
+ def get_sites(data_config: DataConfigModel) -> dict[str, SiteModel]:
276
+ sites = {}
277
+ for site in data_config.sites:
278
+ path = join(data_config.path, site.path)
279
+ # Instantiate the model
280
+ sites[site.id] = SiteModel(
281
+ id = site.id,
282
+ name = site.name,
283
+ description = site.description,
284
+ sky_transmissions = get_transmissions(path, site.transmission),
285
+ sky_emissions = get_emissions(path, site.emission),
286
+ default = site.default
287
+ )
288
+ return sites
289
+
290
+
291
+ def get_telescopes(data_config: DataConfigModel) -> dict[str, TelescopeModel]:
292
+ telescopes = {}
293
+ for telescope in data_config.telescopes:
294
+ path = join(data_config.path, telescope.path)
295
+ # Instantiate the model
296
+ transmissions = get_transmissions(path, telescope.transmission)
297
+ emissions = get_emissions(path, telescope.emission, transmissions)
298
+ telescopes[telescope.id] = TelescopeModel(
299
+ id = telescope.id,
300
+ name = telescope.name,
301
+ description = telescope.description,
302
+ collecting_area = telescope.collecting_area,
303
+ obstruction_area = telescope.obstruction_area,
304
+ transmissions = transmissions,
305
+ emissions = emissions,
306
+ default = telescope.default
307
+ )
308
+ return telescopes
309
+
310
+
311
+ def get_transmission(
312
+ file: IO | PathLike | str,
313
+ id: str,
314
+ name: str="",
315
+ description: str="",
316
+ vars: dict[str, float | str]={}
317
+ ) -> TransmissionModel:
318
+ data = get_data_file(file)
319
+ # Instantiate the model
320
+ wave = u.Quantity(data['WAVELENGTH'])
321
+ response = u.Quantity(data['THROUGHPUT'])
322
+ with warnings.catch_warnings():
323
+ warnings.filterwarnings(
324
+ "ignore",
325
+ message=".*negative flux or throughput.*",
326
+ category=AstropyUserWarning,
327
+ )
328
+ transmission = TransmissionModel(
329
+ id = id,
330
+ name = name,
331
+ description = description,
332
+ vars = vars,
333
+ # Apply tapering to filters to avoid possible spurious spectral leaks
334
+ spectral = SpectralElement.from_spectrum1d(
335
+ Spectrum(spectral_axis=wave, flux=response),
336
+ keep_neg=False
337
+ ).taper()
338
+ )
339
+ return transmission
340
+
341
+
342
+ def get_transmissions(
343
+ parent_dir: str,
344
+ transmission_config: TransmissionConfigModel) -> dict[str, TransmissionModel]:
345
+ transmissions : dict[str, TransmissionModel] = {}
346
+ for file_config in transmission_config.files:
347
+ key = file_config.id if file_config.id != '' else str(len(transmissions))
348
+ transmissions[key] = get_transmission(
349
+ file=join(parent_dir, transmission_config.path, file_config.file),
350
+ id=file_config.id,
351
+ name=file_config.name,
352
+ description=file_config.description,
353
+ vars=file_config.vars
354
+ )
355
+ return transmissions
356
+
357
+
358
+ def get_webapi_instruments(instruments: dict[str, InstrumentModel]) -> dict[str, InstrumentModel]:
359
+ winstruments = {}
360
+ for instrument in instruments:
361
+ winstruments[instrument] = instruments[instrument].copy(exclude={
362
+ 'site': {'sky_emissions', 'sky_transmissions'},
363
+ 'transmissions' : True,
364
+ 'emissions_ct' : True
365
+ })
366
+ return winstruments
367
+
pydiet/server/image.py ADDED
@@ -0,0 +1,342 @@
1
+ """
2
+ Image simulation module
3
+ """
4
+ # Copyright CFHT
5
+ # Licensed under the MIT licence
6
+
7
+ from typing import Literal, Tuple
8
+
9
+ from astropy import units as u #type: ignore[import-untyped]
10
+ from base64 import b64encode
11
+ from io import BytesIO
12
+ from PIL.Image import fromarray
13
+ import numpy as np
14
+ from scipy.optimize import brentq, minimize_scalar #type: ignore[import-untyped]
15
+ from synphot import Observation, SpectralElement #type: ignore[import-untyped]
16
+
17
+ from .models.types import PhotometryID, SourceID
18
+
19
+
20
+
21
+ class Image(object):
22
+ """
23
+ Raster image generation class for computing exposure times and SNRs
24
+
25
+ Examples
26
+ --------
27
+ >>> from astropy import units as u
28
+
29
+ >>> img = Image(
30
+ ... source='point_source',
31
+ ... psf_fwhm=0.8 * u.arcsec,
32
+ ... psf_beta=3.2,
33
+ ... pixel=(0.186 * u.arcsec, 0.186 * u.arcsec),
34
+ ... rate=42.,
35
+ ... bkg_rate=10.,
36
+ ... ron=4.,
37
+ ... gain=1.65,
38
+ ... photometry='model_fitting'
39
+ ... )
40
+
41
+ >>> # Compute SNR from exposure time
42
+ >>> print(f"{img.snr(etime=10.):.1f}")
43
+ 4.6
44
+
45
+ >>> # Compute Exposure time from SNR
46
+ >>> print(f"{img.etime(snr=10.):.1f}")
47
+ 43.0
48
+
49
+
50
+ Parameters
51
+ ----------
52
+ source: Literal['point_source', 'galaxy', 'extended'], optional
53
+ Source type.
54
+ psf_fwhm: ~astropy.units.Quantity['angle'], optional
55
+ Full Width at Half Maximum of the Point Spread Function.
56
+ psf_beta: float, optional
57
+ Moffat beta parameter of the Point Spread Function.
58
+ sersic_radius: ~astropy.units.Quantity['angle'], optional
59
+ Half-light radius for Sersic galaxy profiles.
60
+ sersic_index: float, optional
61
+ Sersic index for galaxy profiles.
62
+ pixel: ~astropy.units.Quantity['angle'], optional
63
+ Pixel scale on each axis.
64
+ image_size: Tuple[int, int], optional
65
+ Image size in pixels on each axis.
66
+ rate: float, optional
67
+ Number of photons per second.
68
+ bkg_rate: float, optional
69
+ Total background photon rate in photons per second per pixel.
70
+ ron: float, optional
71
+ Detector read out noise standard deviation in electrons.
72
+ gain: float, optional
73
+ Detector conversion factor in e-/ADU.
74
+ full_well: float, optional
75
+ Detector full well in electrons.
76
+ range: int, optional
77
+ Digital range, in analog-to-digital converter steps.
78
+ bias: float, optional
79
+ Detector bias in ADUs.
80
+ photometry: Literal['model_fitting', 'fixed_aperture', 'optimal_aperture', 'large_aperture']
81
+ Photometric measurement type.
82
+ aperture: float, optional
83
+ Aperture diameter in pixels for fixed aperture photometry.
84
+ oversamp: int, optional
85
+ Number of oversampling sub pixels on each axis.
86
+ max_etime: ~astropy.units.Quantity['time'], optional
87
+ Maximum possible exposure time in output
88
+ """
89
+ def __init__(
90
+ self,
91
+ source: SourceID='point_source',
92
+ psf_fwhm: u.Quantity['angle']=1.*u.arcsec, #type: ignore[name-defined]
93
+ psf_beta: float=3.2,
94
+ sersic_radius: u.Quantity['angle']=1.*u.arcsec, #type: ignore[name-defined]
95
+ sersic_index: float=1.,
96
+ pixel: u.Quantity['angle']=(0.2, 0.2)*u.arcsec, #type: ignore[name-defined]
97
+ image_size: Tuple[int, int]=(64, 64),
98
+ rate: float=1.,
99
+ bkg_rate: float=0.,
100
+ ron: float=0.,
101
+ gain: float=1.,
102
+ full_well: float=1e6,
103
+ range: int=65536,
104
+ bias: float=0.,
105
+ photometry: PhotometryID='model_fitting',
106
+ aperture: float=3.,
107
+ oversamp: int=1,
108
+ max_etime: u.Quantity['time'] = 1e9 * u.s) -> None: #type: ignore[name-defined]
109
+
110
+ self.source = source
111
+ self.pixel = pixel
112
+ self.rate = rate
113
+ self.bkg_rate = bkg_rate
114
+ self.ron = ron
115
+ self.var_rate = rate
116
+ self.var_bkg_rate = bkg_rate
117
+ self.var_ron = ron*ron
118
+ self.gain = gain
119
+ self.oversamp = oversamp
120
+ self.photometry = photometry
121
+ self.saturation = min(range - 1. - bias, full_well / gain)
122
+ self.max_etime = max_etime.to(u.s).value
123
+
124
+ # Create image coordinate rasters
125
+ raster_size = [image_size[0] * oversamp, image_size[1] * oversamp]
126
+ yx = np.mgrid[
127
+ -raster_size[0]//2:raster_size[0] - raster_size[0]//2,
128
+ -raster_size[1]//2:raster_size[1] - raster_size[1]//2
129
+ ].astype(np.float32)
130
+ r2 = yx[0]**2 + yx[1]**2
131
+ self.r2 = r2
132
+
133
+ # Create truncation disk
134
+ self.mask_r2 = r2[0, raster_size[1]//2]
135
+ self.mask = r2 <= self.mask_r2
136
+
137
+ self.pixel_area = (pixel[0] * pixel[1]) / oversamp**2 * u.pix**2
138
+
139
+ if source == 'extended':
140
+ self.image = self.extended()
141
+ return
142
+
143
+ # Rasterize the PSF
144
+ if psf_beta <= 1.:
145
+ raise ValueError("Moffat beta must be > 1.")
146
+
147
+ # Compute the square of the alpha parameter from the FWHM
148
+ alpha2 = u.Quantity(psf_fwhm)**2 / (4. * (2.**(1./psf_beta) - 1.)) \
149
+ / self.pixel_area
150
+
151
+ # Create PSF raster
152
+ moffat = np.power(1. + r2 / alpha2, -psf_beta)
153
+
154
+ # Truncate inside a disk
155
+ moffat *= self.mask
156
+ self.psf = moffat / moffat.sum()
157
+
158
+ # Generate star or galaxy image
159
+ self.image = self.sersic(
160
+ re=sersic_radius,
161
+ n=sersic_index
162
+ ) if source == 'galaxy' else self.psf
163
+
164
+ # Create photometry measurement aperture
165
+ if self.photometry != 'model_fitting':
166
+ if self.photometry == 'fixed_aperture':
167
+ # User-provided aperture diameter
168
+ r2max = aperture**2 * u.arcsec**2 / self.pixel_area
169
+ elif self.photometry == 'large_aperture':
170
+ # Aperture enclosing 96% of the flux
171
+ r2max = brentq(
172
+ f = lambda r2: np.sum((self.r2 <= r2) * self.image) - 0.96,
173
+ a=0.,
174
+ b=self.mask_r2,
175
+ xtol=1e-3,
176
+ maxiter=100
177
+ )
178
+ elif self.photometry == 'optimal_aperture':
179
+ r2max = 0.
180
+ self.aperture = r2 < r2max
181
+
182
+
183
+ def delta_snr2(self, etime: float, snr: float) -> float:
184
+ return self.snr(etime=etime)**2 - snr**2
185
+
186
+
187
+ def etime(self, snr: float) -> float:
188
+ return brentq(
189
+ f=self.delta_snr2,
190
+ a=0.,
191
+ b=self.etime_max(snr),
192
+ args=(snr),
193
+ xtol=1e-6,
194
+ maxiter=100
195
+ ) if self.rate > 0. else self.max_etime
196
+
197
+
198
+ def etime_bkg_sat(self) -> float:
199
+ return self.saturation / self.bkg_rate if self.bkg_rate > 0. else self.max_etime
200
+
201
+
202
+ def etime_max(self, snr:float) -> float:
203
+ # Find exposure time range for root finding
204
+ t_high = 1.
205
+ while (tsnr:=self.snr(t_high)) < snr and tsnr < 1.e12:
206
+ t_high *= 2.
207
+ return t_high
208
+
209
+
210
+ def etime_source_sat(self) -> float:
211
+ return self.saturation / self.max() if self.rate > 0. and self.bkg_rate > 0. \
212
+ else self.max_etime
213
+
214
+
215
+ def extended(self) -> np.ndarray:
216
+ return self.mask * self.pixel_area.to(u.arcsec**2).value
217
+
218
+
219
+ def gif(self, etime: float, exposures: int=1, frames: int=10) -> str:
220
+ # Initialize random generator
221
+ rng = np.random.default_rng()
222
+ # Use the PSF as a template image and generate a noiseless image
223
+ noiseless = (self.rate * np.array([self.image] * frames) + self.bkg_rate) * etime
224
+ # Generate Poisson + Gaussian noise realizations
225
+ # We add a 3 sigma offset above the background to prevent negative values
226
+ sigmas = 3.*(self.ron*self.ron + self.bkg_rate * etime)**0.5 \
227
+ / np.sqrt(exposures)
228
+ offset = sigmas - self.bkg_rate * etime
229
+ nmax = (noiseless.max() + offset + sigmas) / self.gain
230
+ noisy = np.round(
231
+ exposures * (
232
+ rng.poisson(lam=noiseless*exposures) / exposures + rng.normal(
233
+ loc=offset,
234
+ scale=self.ron / np.sqrt(exposures),
235
+ size=noiseless.shape
236
+ )
237
+ ) / self.gain
238
+ ) / exposures
239
+ # Normalize to a max of 1
240
+ noisy[noisy < 0.] = 0.
241
+ noisy /= nmax
242
+ noisy[noisy > 1.] = 1.
243
+ # Apply sRGB gamma correction and convert to 0...255 unsigned integers
244
+ noisy = (
245
+ np.where(
246
+ noisy <= 0.0031308,
247
+ 12.92 * noisy,
248
+ 1.055 * np.power(noisy, 1./2.4) - 0.055
249
+ ) * 255.
250
+ ).astype(np.uint8)
251
+
252
+ # Create image buffer
253
+ buffer = BytesIO()
254
+ # Save GIF to buffer and append the rest of the animation
255
+ fromarray(noisy[0], mode="L").save(
256
+ buffer,
257
+ format='GIF',
258
+ save_all=True,
259
+ append_images=[fromarray(im) for im in noisy[1:]],
260
+ duration=100,
261
+ loop=0
262
+ )
263
+ buffer.seek(0)
264
+ # Encode GIF as base64
265
+ gif_base64 = b64encode(buffer.read()).decode("utf-8")
266
+ return f"data:image/gif;base64,{gif_base64}"
267
+
268
+
269
+ def max(self) -> float:
270
+ return self.rate * self.image.max() + self.bkg_rate
271
+
272
+
273
+ def sersic(
274
+ self,
275
+ re: u.Quantity['angle']=1.*u.arcsec, #type: ignore[name-defined]
276
+ n: float=1.) -> np.ndarray:
277
+ # Model validity limits
278
+ if n < 0.36:
279
+ n = 0.36
280
+ # R_e normalization from Ciotti & Bertin 1999
281
+ bn = 2.* n - 0.333333 + 9.8765e-3*n**(-1) + 1.8029e-3 * n**(-2) \
282
+ + 1.1409e-4 * n**(-3) - 7.151e-5 * n**(-4)
283
+ inv2n = 0.5 / n
284
+ invre2 = (self.pixel_area / re**2).value
285
+ sersic = np.exp(-bn * (np.power(self.r2 * invre2, inv2n) - 1.))
286
+ sersic = np.fft.irfft2(
287
+ np.fft.rfft2(sersic) * np.fft.rfft2(np.fft.fftshift(self.psf))
288
+ ) * self.mask
289
+ return sersic / np.sum(sersic)
290
+
291
+
292
+ def snr(self, etime: float=1.) -> float:
293
+ # First treat special case of extended source
294
+ if self.source == 'extended':
295
+ # Compute pixel area in arcsec2
296
+ invarea = 1. / self.pixel_area.to(u.arcsec**2).value
297
+ return self.rate * etime / np.sqrt(
298
+ (self.var_bkg_rate * invarea + self.var_rate) * etime \
299
+ + self.var_ron * invarea
300
+ )
301
+ # Compute the "noise variance image"
302
+ img2 = self.image**2
303
+ var_tot = self.var_ron + (
304
+ self.var_bkg_rate + self.var_rate * self.image
305
+ ) * etime
306
+ if self.photometry == 'optimal_aperture':
307
+ # (Re-)compute optimal aperture
308
+ res = minimize_scalar(
309
+ fun = lambda r2: - self.snr_aper(
310
+ self.rate* etime,
311
+ self.image,
312
+ var_tot,
313
+ self.r2 < r2,
314
+ ),
315
+ bounds=(0., self.mask_r2),
316
+ method='bounded'
317
+ )
318
+ # Return SNR at optimal aperture
319
+ return -res.fun
320
+ elif self.photometry == 'model_fitting':
321
+ # Return model-fitting SNR
322
+ return self.rate * etime * np.sqrt(
323
+ np.sum(img2 / var_tot + img2 / (2. * var_tot**2))
324
+ )
325
+ else:
326
+ # Return SNR for a predefined aperture
327
+ return self.snr_aper(
328
+ self.rate * etime,
329
+ self.image,
330
+ var_tot,
331
+ self.aperture
332
+ )
333
+
334
+ def snr_aper(
335
+ self,
336
+ photons: float,
337
+ obj: np.ndarray,
338
+ var: np.ndarray,
339
+ aper: np.ndarray) -> float:
340
+ return photons * np.sum(obj * aper) / np.sqrt(np.sum(var * aper))
341
+
342
+