rubin-sim 2.6.1.dev2__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 (387) hide show
  1. batch/compile_prenight_metadata_cache.sh +34 -0
  2. batch/run_auxtel_prenight_sims.sh +163 -0
  3. batch/run_prenight_sims.sh +239 -0
  4. docs/.gitignore +3 -0
  5. docs/Makefile +32 -0
  6. docs/api.rst +25 -0
  7. docs/conf.py +8 -0
  8. docs/data-api.rst +12 -0
  9. docs/data-download.rst +70 -0
  10. docs/developer.rst +14 -0
  11. docs/documenteer.toml +11 -0
  12. docs/index.rst +22 -0
  13. docs/installation.rst +76 -0
  14. docs/introduction.rst +37 -0
  15. docs/maf-api-batches.rst +12 -0
  16. docs/maf-api-db.rst +12 -0
  17. docs/maf-api-maf-contrib.rst +12 -0
  18. docs/maf-api-maps.rst +12 -0
  19. docs/maf-api-metricbundles.rst +12 -0
  20. docs/maf-api-metrics.rst +12 -0
  21. docs/maf-api-plots.rst +12 -0
  22. docs/maf-api-run-comparison.rst +12 -0
  23. docs/maf-api-slicers.rst +12 -0
  24. docs/maf-api-stackers.rst +12 -0
  25. docs/maf-api-utils.rst +12 -0
  26. docs/maf-api.rst +22 -0
  27. docs/maf.rst +60 -0
  28. docs/metric_list.py +49 -0
  29. docs/moving-objects-api.rst +12 -0
  30. docs/moving-objects.rst +22 -0
  31. docs/phot-utils-api.rst +12 -0
  32. docs/phot-utils.rst +11 -0
  33. docs/prenight.rst +117 -0
  34. docs/protoarchive.rst +276 -0
  35. docs/satellite-constellations-api.rst +12 -0
  36. docs/satellite-constellations.rst +13 -0
  37. docs/selfcal-api.rst +12 -0
  38. docs/selfcal.rst +15 -0
  39. docs/sim-archive-api.rst +12 -0
  40. docs/skybrightness-api.rst +12 -0
  41. docs/skybrightness.rst +23 -0
  42. docs/user-guide.rst +28 -0
  43. docs/vseqarchive-architecture.rst +438 -0
  44. docs/vseqarchive-setup.rst +238 -0
  45. docs/vseqarchive.rst +225 -0
  46. rubin_sim/__init__.py +7 -0
  47. rubin_sim/data/__init__.py +1 -0
  48. rubin_sim/data/rs_download_data.py +155 -0
  49. rubin_sim/maf/__init__.py +34 -0
  50. rubin_sim/maf/batches/__init__.py +18 -0
  51. rubin_sim/maf/batches/altaz_batch.py +161 -0
  52. rubin_sim/maf/batches/col_map_dict.py +274 -0
  53. rubin_sim/maf/batches/common.py +388 -0
  54. rubin_sim/maf/batches/ddf_batch.py +728 -0
  55. rubin_sim/maf/batches/filterchange_batch.py +182 -0
  56. rubin_sim/maf/batches/glance_batch.py +591 -0
  57. rubin_sim/maf/batches/hourglass_batch.py +79 -0
  58. rubin_sim/maf/batches/info_batch.py +114 -0
  59. rubin_sim/maf/batches/metadata_batch.py +558 -0
  60. rubin_sim/maf/batches/moving_objects_batch.py +1587 -0
  61. rubin_sim/maf/batches/openshutter_batch.py +100 -0
  62. rubin_sim/maf/batches/radar_limited.py +1100 -0
  63. rubin_sim/maf/batches/science_radar_batch.py +1910 -0
  64. rubin_sim/maf/batches/skycoverage.py +155 -0
  65. rubin_sim/maf/batches/slew_batch.py +146 -0
  66. rubin_sim/maf/batches/srd_batch.py +637 -0
  67. rubin_sim/maf/batches/time_batch.py +757 -0
  68. rubin_sim/maf/batches/time_sci_batch.py +105 -0
  69. rubin_sim/maf/batches/visitdepth_batch.py +485 -0
  70. rubin_sim/maf/db/__init__.py +2 -0
  71. rubin_sim/maf/db/add_run.py +34 -0
  72. rubin_sim/maf/db/results_db.py +1074 -0
  73. rubin_sim/maf/db/tracking_db.py +332 -0
  74. rubin_sim/maf/ddf_dir.py +53 -0
  75. rubin_sim/maf/generate_ss.py +218 -0
  76. rubin_sim/maf/glance_dir.py +58 -0
  77. rubin_sim/maf/maf_contrib/__init__.py +25 -0
  78. rubin_sim/maf/maf_contrib/calculate_lsst_field_visibility_astropy.py +170 -0
  79. rubin_sim/maf/maf_contrib/depth_limited_num_gal_metric.py +109 -0
  80. rubin_sim/maf/maf_contrib/example_new_metrics.py +40 -0
  81. rubin_sim/maf/maf_contrib/filter_pair_t_gaps_metric.py +208 -0
  82. rubin_sim/maf/maf_contrib/grb_transient_metric.py +249 -0
  83. rubin_sim/maf/maf_contrib/gw170817_det_metric.py +72 -0
  84. rubin_sim/maf/maf_contrib/intervals_between_obs_metric.py +68 -0
  85. rubin_sim/maf/maf_contrib/kne_metrics.py +473 -0
  86. rubin_sim/maf/maf_contrib/lss_metrics.py +45 -0
  87. rubin_sim/maf/maf_contrib/lss_obs_strategy/__init__.py +3 -0
  88. rubin_sim/maf/maf_contrib/lss_obs_strategy/constants_for_pipeline.py +73 -0
  89. rubin_sim/maf/maf_contrib/lss_obs_strategy/galaxy_counts_metric_extended.py +205 -0
  90. rubin_sim/maf/maf_contrib/lss_obs_strategy/galaxy_counts_with_pixel_calibration.py +171 -0
  91. rubin_sim/maf/maf_contrib/lv_dwarfs/__init__.py +1 -0
  92. rubin_sim/maf/maf_contrib/lv_dwarfs/lv_dwarfs_metrics.py +388 -0
  93. rubin_sim/maf/maf_contrib/microlensing_metric.py +593 -0
  94. rubin_sim/maf/maf_contrib/num_obs_in_survey_time_overlap_metric.py +43 -0
  95. rubin_sim/maf/maf_contrib/periodic_metric.py +51 -0
  96. rubin_sim/maf/maf_contrib/periodic_star_metric.py +186 -0
  97. rubin_sim/maf/maf_contrib/periodic_star_modulation_metric.py +236 -0
  98. rubin_sim/maf/maf_contrib/presto_color_kne_pop_metric.py +441 -0
  99. rubin_sim/maf/maf_contrib/selfcal_uniformity_metric.py +171 -0
  100. rubin_sim/maf/maf_contrib/star_count_mass_metric.py +64 -0
  101. rubin_sim/maf/maf_contrib/star_count_metric.py +56 -0
  102. rubin_sim/maf/maf_contrib/star_counts/__init__.py +8 -0
  103. rubin_sim/maf/maf_contrib/star_counts/abs_mag.py +324 -0
  104. rubin_sim/maf/maf_contrib/star_counts/coords.py +99 -0
  105. rubin_sim/maf/maf_contrib/star_counts/readme.txt +1 -0
  106. rubin_sim/maf/maf_contrib/star_counts/spec_type.py +91 -0
  107. rubin_sim/maf/maf_contrib/star_counts/starcount.py +30 -0
  108. rubin_sim/maf/maf_contrib/star_counts/starcount_bymass.py +93 -0
  109. rubin_sim/maf/maf_contrib/star_counts/stellardensity.py +59 -0
  110. rubin_sim/maf/maf_contrib/static_probes_fom_summary_metric.py +386 -0
  111. rubin_sim/maf/maf_contrib/tdes_pop_metric.py +362 -0
  112. rubin_sim/maf/maf_contrib/transient_ascii_sed_metric.py +652 -0
  113. rubin_sim/maf/maf_contrib/triplet_metric.py +156 -0
  114. rubin_sim/maf/maf_contrib/var_depth_metric.py +157 -0
  115. rubin_sim/maf/maf_contrib/xrb_metrics.py +523 -0
  116. rubin_sim/maf/maf_contrib/young_stellar_objects_metric.py +200 -0
  117. rubin_sim/maf/maf_night_report.py +200 -0
  118. rubin_sim/maf/make_fbs_tracking_db.py +90 -0
  119. rubin_sim/maf/maps/__init__.py +8 -0
  120. rubin_sim/maf/maps/base_map.py +72 -0
  121. rubin_sim/maf/maps/create_gaia_density_map.py +158 -0
  122. rubin_sim/maf/maps/dust_map.py +66 -0
  123. rubin_sim/maf/maps/dust_map_3d.py +190 -0
  124. rubin_sim/maf/maps/ebv_3d_hp.py +170 -0
  125. rubin_sim/maf/maps/ebv_hp.py +64 -0
  126. rubin_sim/maf/maps/gal_coords_map.py +22 -0
  127. rubin_sim/maf/maps/galactic_plane_priority_maps.py +210 -0
  128. rubin_sim/maf/maps/stellar_density_map.py +73 -0
  129. rubin_sim/maf/maps/trilegal_map.py +82 -0
  130. rubin_sim/maf/metadata_dir.py +90 -0
  131. rubin_sim/maf/metric_bundles/__init__.py +3 -0
  132. rubin_sim/maf/metric_bundles/metric_bundle.py +816 -0
  133. rubin_sim/maf/metric_bundles/metric_bundle_group.py +834 -0
  134. rubin_sim/maf/metric_bundles/mo_metric_bundle.py +646 -0
  135. rubin_sim/maf/metrics/__init__.py +52 -0
  136. rubin_sim/maf/metrics/agn_time_lag_metric.py +118 -0
  137. rubin_sim/maf/metrics/agnstructure.py +141 -0
  138. rubin_sim/maf/metrics/area_summary_metrics.py +120 -0
  139. rubin_sim/maf/metrics/base_metric.py +215 -0
  140. rubin_sim/maf/metrics/brown_dwarf_metric.py +162 -0
  141. rubin_sim/maf/metrics/cadence_metrics.py +400 -0
  142. rubin_sim/maf/metrics/calibration_metrics.py +618 -0
  143. rubin_sim/maf/metrics/chip_vendor_metric.py +43 -0
  144. rubin_sim/maf/metrics/color_slope_metrics.py +189 -0
  145. rubin_sim/maf/metrics/cosmology_summary_metrics.py +1212 -0
  146. rubin_sim/maf/metrics/coverage_metric.py +48 -0
  147. rubin_sim/maf/metrics/crowding_metric.py +255 -0
  148. rubin_sim/maf/metrics/cumulative_metric.py +46 -0
  149. rubin_sim/maf/metrics/dcr_metric.py +93 -0
  150. rubin_sim/maf/metrics/exgal_m5.py +57 -0
  151. rubin_sim/maf/metrics/fft_metric.py +27 -0
  152. rubin_sim/maf/metrics/galactic_plane_metrics.py +375 -0
  153. rubin_sim/maf/metrics/galplane_time_sampling_metrics.py +236 -0
  154. rubin_sim/maf/metrics/hourglass_metric.py +130 -0
  155. rubin_sim/maf/metrics/incremental_template_metric.py +255 -0
  156. rubin_sim/maf/metrics/kuiper_metrics.py +26 -0
  157. rubin_sim/maf/metrics/mo_metrics.py +1496 -0
  158. rubin_sim/maf/metrics/mo_summary_metrics.py +464 -0
  159. rubin_sim/maf/metrics/night_pointing_metric.py +70 -0
  160. rubin_sim/maf/metrics/optimal_m5_metric.py +94 -0
  161. rubin_sim/maf/metrics/pair_metric.py +69 -0
  162. rubin_sim/maf/metrics/periodic_detect_metric.py +133 -0
  163. rubin_sim/maf/metrics/phase_gap_metric.py +172 -0
  164. rubin_sim/maf/metrics/qso_number_counts_metric.py +138 -0
  165. rubin_sim/maf/metrics/scaling_metrics.py +148 -0
  166. rubin_sim/maf/metrics/schedview_metrics.py +39 -0
  167. rubin_sim/maf/metrics/season_metrics.py +344 -0
  168. rubin_sim/maf/metrics/simple_metrics.py +546 -0
  169. rubin_sim/maf/metrics/sky_sat_metric.py +18 -0
  170. rubin_sim/maf/metrics/sn_cadence_metric.py +104 -0
  171. rubin_sim/maf/metrics/sn_n_sn_metric.py +1364 -0
  172. rubin_sim/maf/metrics/sn_sl_metric.py +216 -0
  173. rubin_sim/maf/metrics/sn_snr_metric.py +715 -0
  174. rubin_sim/maf/metrics/snr_weight.py +24 -0
  175. rubin_sim/maf/metrics/star_density.py +77 -0
  176. rubin_sim/maf/metrics/string_count_metric.py +95 -0
  177. rubin_sim/maf/metrics/summary_metrics.py +201 -0
  178. rubin_sim/maf/metrics/surfb_metric.py +175 -0
  179. rubin_sim/maf/metrics/technical_metrics.py +271 -0
  180. rubin_sim/maf/metrics/tgaps.py +412 -0
  181. rubin_sim/maf/metrics/tomography_models.py +2034 -0
  182. rubin_sim/maf/metrics/transient_metrics.py +268 -0
  183. rubin_sim/maf/metrics/uniformity_metrics.py +418 -0
  184. rubin_sim/maf/metrics/use_metrics.py +55 -0
  185. rubin_sim/maf/metrics/vector_metrics.py +190 -0
  186. rubin_sim/maf/metrics/visit_groups_metric.py +341 -0
  187. rubin_sim/maf/metrics/weak_lensing_systematics_metric.py +210 -0
  188. rubin_sim/maf/plots/__init__.py +14 -0
  189. rubin_sim/maf/plots/hg_plotters.py +1117 -0
  190. rubin_sim/maf/plots/hourglass_plotters.py +120 -0
  191. rubin_sim/maf/plots/mo_plotters.py +358 -0
  192. rubin_sim/maf/plots/nd_plotters.py +220 -0
  193. rubin_sim/maf/plots/neo_distance_plotter.py +140 -0
  194. rubin_sim/maf/plots/night_pointing_plotter.py +96 -0
  195. rubin_sim/maf/plots/oned_plotters.py +114 -0
  196. rubin_sim/maf/plots/perceptual_rainbow.py +30 -0
  197. rubin_sim/maf/plots/plot_handler.py +766 -0
  198. rubin_sim/maf/plots/skyproj_plotters.py +540 -0
  199. rubin_sim/maf/plots/spatial_plotters.py +1029 -0
  200. rubin_sim/maf/plots/special_plotters.py +256 -0
  201. rubin_sim/maf/plots/two_d_plotters.py +177 -0
  202. rubin_sim/maf/plots/xyplotter.py +37 -0
  203. rubin_sim/maf/run_comparison/__init__.py +5 -0
  204. rubin_sim/maf/run_comparison/archive.py +584 -0
  205. rubin_sim/maf/run_comparison/gather_summaries.py +125 -0
  206. rubin_sim/maf/run_comparison/microlensing_compare.py +514 -0
  207. rubin_sim/maf/run_comparison/radar_plot.py +202 -0
  208. rubin_sim/maf/run_comparison/summary_plots.py +710 -0
  209. rubin_sim/maf/run_moving_calc.py +239 -0
  210. rubin_sim/maf/run_moving_fractions.py +128 -0
  211. rubin_sim/maf/run_moving_join.py +103 -0
  212. rubin_sim/maf/run_selfcal_metric.py +92 -0
  213. rubin_sim/maf/scimaf_dir.py +87 -0
  214. rubin_sim/maf/show_maf.py +227 -0
  215. rubin_sim/maf/slicers/__init__.py +13 -0
  216. rubin_sim/maf/slicers/base_slicer.py +513 -0
  217. rubin_sim/maf/slicers/base_spatial_slicer.py +202 -0
  218. rubin_sim/maf/slicers/healpix_sdss_slicer.py +118 -0
  219. rubin_sim/maf/slicers/healpix_slicer.py +189 -0
  220. rubin_sim/maf/slicers/healpix_subset_slicer.py +293 -0
  221. rubin_sim/maf/slicers/hourglass_slicer.py +37 -0
  222. rubin_sim/maf/slicers/mo_slicer.py +171 -0
  223. rubin_sim/maf/slicers/movie_slicer.py +288 -0
  224. rubin_sim/maf/slicers/nd_slicer.py +147 -0
  225. rubin_sim/maf/slicers/one_d_slicer.py +210 -0
  226. rubin_sim/maf/slicers/time_interval_slicers.py +283 -0
  227. rubin_sim/maf/slicers/uni_slicer.py +57 -0
  228. rubin_sim/maf/slicers/user_points_slicer.py +141 -0
  229. rubin_sim/maf/stackers/__init__.py +13 -0
  230. rubin_sim/maf/stackers/base_stacker.py +221 -0
  231. rubin_sim/maf/stackers/coord_stackers.py +224 -0
  232. rubin_sim/maf/stackers/date_stackers.py +192 -0
  233. rubin_sim/maf/stackers/general_stackers.py +683 -0
  234. rubin_sim/maf/stackers/get_col_info.py +104 -0
  235. rubin_sim/maf/stackers/label_stackers.py +114 -0
  236. rubin_sim/maf/stackers/m5_optimal_stacker.py +126 -0
  237. rubin_sim/maf/stackers/mo_phase.py +265 -0
  238. rubin_sim/maf/stackers/mo_stackers.py +486 -0
  239. rubin_sim/maf/stackers/n_follow_stacker.py +187 -0
  240. rubin_sim/maf/stackers/neo_dist_stacker.py +126 -0
  241. rubin_sim/maf/stackers/sdss_stackers.py +30 -0
  242. rubin_sim/maf/stackers/sn_stacker.py +152 -0
  243. rubin_sim/maf/stackers/teff_stacker.py +154 -0
  244. rubin_sim/maf/utils/__init__.py +6 -0
  245. rubin_sim/maf/utils/astrometry_utils.py +93 -0
  246. rubin_sim/maf/utils/generate_fov_map.py +58 -0
  247. rubin_sim/maf/utils/get_date_version.py +28 -0
  248. rubin_sim/maf/utils/maf_utils.py +212 -0
  249. rubin_sim/maf/utils/opsim_utils.py +259 -0
  250. rubin_sim/maf/utils/output_utils.py +34 -0
  251. rubin_sim/maf/utils/sn_n_sn_utils.py +894 -0
  252. rubin_sim/maf/utils/sn_utils.py +369 -0
  253. rubin_sim/maf/utils/stellar_mags.py +197 -0
  254. rubin_sim/maf/web/__init__.py +2 -0
  255. rubin_sim/maf/web/favicon.ico +0 -0
  256. rubin_sim/maf/web/maf_run_results.py +645 -0
  257. rubin_sim/maf/web/maf_tracking.py +139 -0
  258. rubin_sim/maf/web/sorttable.js +496 -0
  259. rubin_sim/maf/web/templates/allmetricresults.html +72 -0
  260. rubin_sim/maf/web/templates/configs.html +28 -0
  261. rubin_sim/maf/web/templates/macros.html +243 -0
  262. rubin_sim/maf/web/templates/master.html +294 -0
  263. rubin_sim/maf/web/templates/metricselect.html +91 -0
  264. rubin_sim/maf/web/templates/multicolor.html +147 -0
  265. rubin_sim/maf/web/templates/results.html +82 -0
  266. rubin_sim/maf/web/templates/runselect.html +58 -0
  267. rubin_sim/maf/web/templates/stats.html +50 -0
  268. rubin_sim/moving_objects/__init__.py +8 -0
  269. rubin_sim/moving_objects/base_obs.py +468 -0
  270. rubin_sim/moving_objects/cheby_fits.py +689 -0
  271. rubin_sim/moving_objects/cheby_values.py +231 -0
  272. rubin_sim/moving_objects/chebyshev_utils.py +365 -0
  273. rubin_sim/moving_objects/direct_obs.py +376 -0
  274. rubin_sim/moving_objects/make_lsst_obs.py +282 -0
  275. rubin_sim/moving_objects/ooephemerides.py +643 -0
  276. rubin_sim/moving_objects/orbits.py +507 -0
  277. rubin_sim/moving_objects/pre_generate.py +37 -0
  278. rubin_sim/moving_objects/utils.py +61 -0
  279. rubin_sim/phot_utils/__init__.py +7 -0
  280. rubin_sim/phot_utils/bandpass.py +425 -0
  281. rubin_sim/phot_utils/photometric_parameters.py +449 -0
  282. rubin_sim/phot_utils/physical_parameters.py +50 -0
  283. rubin_sim/phot_utils/predicted_zeropoints.py +245 -0
  284. rubin_sim/phot_utils/sed.py +1773 -0
  285. rubin_sim/phot_utils/sed_utils.py +52 -0
  286. rubin_sim/phot_utils/signaltonoise.py +695 -0
  287. rubin_sim/phot_utils/spectral_resampling.py +161 -0
  288. rubin_sim/satellite_constellations/__init__.py +3 -0
  289. rubin_sim/satellite_constellations/basis_function.py +40 -0
  290. rubin_sim/satellite_constellations/model_observatory.py +157 -0
  291. rubin_sim/satellite_constellations/sat_utils.py +488 -0
  292. rubin_sim/selfcal/__init__.py +4 -0
  293. rubin_sim/selfcal/generate_catalog.py +188 -0
  294. rubin_sim/selfcal/offsets.py +119 -0
  295. rubin_sim/selfcal/solver.py +143 -0
  296. rubin_sim/selfcal/star_tools.py +42 -0
  297. rubin_sim/sim_archive/__init__.py +14 -0
  298. rubin_sim/sim_archive/conda_packages.py +65 -0
  299. rubin_sim/sim_archive/future_vsarch.py +480 -0
  300. rubin_sim/sim_archive/make_snapshot.py +255 -0
  301. rubin_sim/sim_archive/prenight.py +216 -0
  302. rubin_sim/sim_archive/prenightindex.py +229 -0
  303. rubin_sim/sim_archive/prototype.py +1029 -0
  304. rubin_sim/sim_archive/sim_archive.py +680 -0
  305. rubin_sim/sim_archive/tempdb.py +56 -0
  306. rubin_sim/sim_archive/util.py +146 -0
  307. rubin_sim/sim_archive/vseqarchive.py +965 -0
  308. rubin_sim/sim_archive/vseqmetadata.py +1721 -0
  309. rubin_sim/skybrightness/__init__.py +5 -0
  310. rubin_sim/skybrightness/allsky_db.py +93 -0
  311. rubin_sim/skybrightness/data/ESO_Spectra/eso_tools.py +505 -0
  312. rubin_sim/skybrightness/data/solarSpec/package.py +22 -0
  313. rubin_sim/skybrightness/generate_hdf5.py +243 -0
  314. rubin_sim/skybrightness/interp_components.py +798 -0
  315. rubin_sim/skybrightness/sky_model.py +771 -0
  316. rubin_sim/skybrightness/twilight_func.py +83 -0
  317. rubin_sim/skybrightness/utils.py +91 -0
  318. rubin_sim-2.6.1.dev2.dist-info/METADATA +95 -0
  319. rubin_sim-2.6.1.dev2.dist-info/RECORD +387 -0
  320. rubin_sim-2.6.1.dev2.dist-info/WHEEL +5 -0
  321. rubin_sim-2.6.1.dev2.dist-info/entry_points.txt +24 -0
  322. rubin_sim-2.6.1.dev2.dist-info/licenses/LICENSE +674 -0
  323. rubin_sim-2.6.1.dev2.dist-info/top_level.txt +5 -0
  324. sql/make_vseqmeta.sql +251 -0
  325. tests/data/test_data.py +26 -0
  326. tests/data/test_ddf_grid.py +17 -0
  327. tests/maf/test_3x2fom.py +68 -0
  328. tests/maf/test_archive.py +162 -0
  329. tests/maf/test_basemetrics.py +99 -0
  330. tests/maf/test_batchcommon.py +36 -0
  331. tests/maf/test_batches.py +159 -0
  332. tests/maf/test_cadencemetrics.py +388 -0
  333. tests/maf/test_calibrationmetrics.py +242 -0
  334. tests/maf/test_color_slopes.py +71 -0
  335. tests/maf/test_cosmology_summaries.py +20 -0
  336. tests/maf/test_gathersummaries.py +48 -0
  337. tests/maf/test_healpixslicer.py +439 -0
  338. tests/maf/test_healpixsubsetslicer.py +273 -0
  339. tests/maf/test_hgplotters.py +145 -0
  340. tests/maf/test_hourglassmetric.py +33 -0
  341. tests/maf/test_io.py +187 -0
  342. tests/maf/test_json.py +169 -0
  343. tests/maf/test_maps.py +199 -0
  344. tests/maf/test_metricbundle.py +69 -0
  345. tests/maf/test_mometrics.py +422 -0
  346. tests/maf/test_movieslicer.py +216 -0
  347. tests/maf/test_ndslicer.py +240 -0
  348. tests/maf/test_neodistanceplotter.py +41 -0
  349. tests/maf/test_onedslicer.py +245 -0
  350. tests/maf/test_opsimutils.py +80 -0
  351. tests/maf/test_plotters.py +134 -0
  352. tests/maf/test_resultsdb.py +145 -0
  353. tests/maf/test_schedviewmetrics.py +14 -0
  354. tests/maf/test_simplemetrics.py +200 -0
  355. tests/maf/test_snmetrics.py +335 -0
  356. tests/maf/test_snmetrics_nsn.py +123 -0
  357. tests/maf/test_stackers.py +423 -0
  358. tests/maf/test_stellarmags.py +50 -0
  359. tests/maf/test_stringcount.py +26 -0
  360. tests/maf/test_summary_plots.py +197 -0
  361. tests/maf/test_summarymetrics.py +82 -0
  362. tests/maf/test_technicalmetrics.py +267 -0
  363. tests/maf/test_templatemetrics.py +86 -0
  364. tests/maf/test_timeintervalslicers.py +97 -0
  365. tests/maf/test_trackingdb.py +88 -0
  366. tests/maf/test_unislicer.py +116 -0
  367. tests/maf/test_vectormetrics.py +225 -0
  368. tests/maf/test_visitgroupsmetric.py +181 -0
  369. tests/moving_objects/test_camera.py +39 -0
  370. tests/moving_objects/test_chebyfits.py +188 -0
  371. tests/moving_objects/test_chebyshevutils.py +76 -0
  372. tests/moving_objects/test_chebyvalues.py +245 -0
  373. tests/moving_objects/test_ephemerides.py +219 -0
  374. tests/moving_objects/test_orbits.py +170 -0
  375. tests/moving_objects/test_sed_data.py +61 -0
  376. tests/phot_utils/test_approximatebandpasses.py +91 -0
  377. tests/phot_utils/test_photometricparameters.py +268 -0
  378. tests/phot_utils/test_photometry.py +61 -0
  379. tests/phot_utils/test_predicted_zeropoints.py +53 -0
  380. tests/phot_utils/test_read_bandpasses.py +29 -0
  381. tests/phot_utils/test_sed.py +368 -0
  382. tests/phot_utils/test_snr.py +370 -0
  383. tests/satellite_constellations/test_satellites.py +36 -0
  384. tests/sim_archive/test_make_snapshot.py +24 -0
  385. tests/sim_archive/test_prototype_sim_archive.py +204 -0
  386. tests/sim_archive/test_vseqarchive.py +947 -0
  387. tests/skybrightness/test_skymodel.py +359 -0
@@ -0,0 +1,1721 @@
1
+ __all__ = ["compute_visits_sha256", "VisitSequenceArchiveMetadata"]
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import os
7
+ import warnings
8
+ from datetime import date, datetime, timedelta, timezone
9
+ from pathlib import Path
10
+ from tempfile import TemporaryDirectory
11
+ from typing import Any, Dict, Mapping, Tuple
12
+ from uuid import UUID
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import psycopg2
17
+ import psycopg2.extras
18
+ import psycopg2.pool
19
+ import sqlalchemy
20
+ import yaml
21
+ from astropy.time import Time
22
+ from lsst.resources import ResourcePath
23
+ from psycopg2 import sql
24
+
25
+ from .util import compute_conda_env, dayobs_to_date, opsimdb_to_hdf5
26
+
27
+ VSARCHIVE_PGDATABASE = "opsim_log"
28
+ VSARCHIVE_PGHOST = "usdf-maf-visit-seq-archive-tx-ro.sdf.slac.stanford.edu"
29
+ VSARCHIVE_PGUSER = "reader"
30
+ VSARCHIVE_PGSCHEMA = "vsmd"
31
+
32
+ JSON_DUMP_LIMIT = 4096
33
+
34
+ LOGGER = logging.getLogger(__name__)
35
+
36
+ psycopg2.extras.register_uuid()
37
+
38
+
39
+ def compute_visits_sha256(visits: pd.DataFrame) -> bytes:
40
+ recs = visits.to_records()
41
+ visitseq_hash = hashlib.sha256(str(recs.dtype).encode())
42
+ visitseq_hash.update(np.ascontiguousarray(recs).data.tobytes())
43
+ visitseq_sha256 = bytes.fromhex(visitseq_hash.hexdigest())
44
+ return visitseq_sha256
45
+
46
+
47
+ def metadata_yaml_from_prototype(
48
+ sim_date: str | date,
49
+ sim_index: str | int,
50
+ proto_sim_archive_uri: str = "s3://rubin:rubin-scheduler-prenight/opsim/",
51
+ ) -> str:
52
+ """Return a sim metadata YAML derived from the prototype sim archive.
53
+
54
+ Parameters
55
+ ----------
56
+ sim_date : `str` or `datetime.date`
57
+ The simulation date in ISO format or a ``date`` object.
58
+ sim_index : `str` or `int`
59
+ The simulation index identifying the specific simulation
60
+ within the date directory.
61
+ proto_sim_archive_uri : `str`, optional
62
+ Base URI of the simulation archive.
63
+ Defaults to the prototype location in the S3 bucket.
64
+
65
+ Returns
66
+ -------
67
+ sim_metadata: `str`
68
+ `yaml` representation of the simulation metadata.
69
+
70
+ """
71
+ if isinstance(sim_date, date):
72
+ sim_date = sim_date.isoformat()
73
+ assert isinstance(sim_date, str)
74
+
75
+ sim_archive_rp = (
76
+ ResourcePath(proto_sim_archive_uri)
77
+ .join(sim_date, forceDirectory=True)
78
+ .join(str(sim_index), forceDirectory=True)
79
+ )
80
+ sims_found = sim_archive_rp.exists() and sim_archive_rp.join("sim_metadata.yaml").exists()
81
+ if not sim_archive_rp.exists():
82
+ raise ValueError(f"No simulations found at {sim_archive_rp.geturl()}.")
83
+
84
+ if not sim_archive_rp.join("sim_metadata.yaml").exists():
85
+ raise ValueError(
86
+ f"No simulation metadata found at {sim_archive_rp.join('sim_metadata.yaml').geturl()}"
87
+ )
88
+
89
+ if sims_found:
90
+ metadata = yaml.safe_load(sim_archive_rp.join("sim_metadata.yaml").read().decode())
91
+ else:
92
+ raise ValueError("No simulation found")
93
+
94
+ if "label" not in metadata:
95
+ raise ValueError("Metadata yaml must include a label")
96
+
97
+ label = metadata["label"]
98
+
99
+ # If there is no creation time given, try extracting it from the label
100
+ if "creation_time" not in metadata:
101
+ # The prenight labels usually end in the creation time.
102
+ if " ".join(label.split()[-3:-1]) == "run at":
103
+ maybe_date_str = label.split()[-1]
104
+ is_date_str = False
105
+ try:
106
+ datetime.fromisoformat(maybe_date_str)
107
+ is_date_str = True
108
+ except ValueError:
109
+ is_date_str = False
110
+
111
+ if is_date_str:
112
+ metadata["creation_time"] = maybe_date_str
113
+
114
+ # If we still do not have a creation time, invent one from the date
115
+ # and index.
116
+ if "creation_time" not in metadata:
117
+ creation_time = datetime.fromisoformat(sim_date + "T12:00:00Z") + timedelta(seconds=int(sim_index))
118
+ metadata["creation_time"] = creation_time.isoformat()
119
+
120
+ if "files" in metadata:
121
+ for file_type in metadata["files"]:
122
+ if "url" not in metadata["files"][file_type]:
123
+ fname = metadata["files"][file_type]["name"]
124
+ metadata["files"][file_type]["url"] = sim_archive_rp.join(fname).geturl()
125
+
126
+ metadata_yaml = yaml.dump(metadata)
127
+
128
+ return metadata_yaml
129
+
130
+
131
+ class VisitSequenceArchiveMetadata:
132
+ """Interface to metadata database that tracks sequences
133
+ of visits.
134
+
135
+ Parameters
136
+ ----------
137
+ metadata_db_kwargs: `Mapping` or `None`
138
+ A dictionary or other mapping defining the connection
139
+ parameters for connecting to the postgresql database
140
+ that holds the sequence metadata. Keys are passed as keyword
141
+ arguments to ``psycopg2.pool.SimpleConnectionPool``.
142
+ If ``None``, the dictionary is built from the environment
143
+ variables ``VSARCHIVE_PGDATABASE``, ``VSARCHIVE_PGHOST``,
144
+ ``VSARCHIVE_PGUSER``, and ``VSARCHIVE_PGPORT`` (if they
145
+ exist) or corresponding module variables in
146
+ ``rubin_sim.sim_archive.vseqarchiv`` (if they do not).
147
+ metadata_db_schema: `str` or `None`
148
+ The schema in the database holding the metadata. If
149
+ ``None``, defaults to
150
+ ``rubin_sim.sim_archive.vseqarchive.VSARCHIVE_PGSCHEMA``.
151
+ Default is ``None``.
152
+
153
+ Notes
154
+ -----
155
+ The visit sequence archive consists of two primary architectural
156
+ components: an archive in which files associated with a sequence
157
+ of visits are stored (including, optionally, the table of visits
158
+ themselves); and postegresql database with tables with metadata
159
+ about the sequneces of visits, including the
160
+ provenance of the visits, the locations of the files associated
161
+ with each sequence of visits, comments and tags associated with
162
+ each sequence of visits, and basic statistics of the visits.
163
+ This class is an interface to the postgresql metadata database.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ metadata_db_kwargs: Mapping | None = None,
169
+ metadata_db_schema: str | None = None,
170
+ ):
171
+ if metadata_db_schema is None:
172
+ metadata_db_schema = VSARCHIVE_PGSCHEMA
173
+ assert isinstance(metadata_db_schema, str)
174
+
175
+ if not isinstance(metadata_db_kwargs, dict):
176
+ metadata_db_kwargs = {} if metadata_db_kwargs is None else dict(metadata_db_kwargs)
177
+ assert isinstance(metadata_db_kwargs, dict)
178
+
179
+ if "database" not in metadata_db_kwargs:
180
+ if "VSARCHIVE_PGDATABASE" in os.environ:
181
+ metadata_db_kwargs["database"] = os.environ["VSARCHIVE_PGDATABASE"]
182
+ else:
183
+ metadata_db_kwargs["database"] = VSARCHIVE_PGDATABASE
184
+
185
+ if "host" not in metadata_db_kwargs:
186
+ if "VSARCHIVE_PGHOST" in os.environ:
187
+ metadata_db_kwargs["host"] = os.environ["VSARCHIVE_PGHOST"]
188
+ else:
189
+ metadata_db_kwargs["host"] = VSARCHIVE_PGHOST
190
+
191
+ if "user" not in metadata_db_kwargs:
192
+ if "VSARCHIVE_PGUSER" in os.environ:
193
+ metadata_db_kwargs["user"] = os.environ["VSARCHIVE_PGUSER"]
194
+ else:
195
+ metadata_db_kwargs["user"] = VSARCHIVE_PGUSER
196
+
197
+ if "port" not in metadata_db_kwargs and "VSARCHIVE_PGPORT" in os.environ:
198
+ metadata_db_kwargs["port"] = os.environ["VSARCHIVE_PGPORT"]
199
+
200
+ self.pg_pool = psycopg2.pool.SimpleConnectionPool(1, 5, **metadata_db_kwargs)
201
+ # On some operations, pandas does not
202
+ # work well directly with the psycopg2
203
+ # connection, but can work with sqlalchemy.
204
+ # Make and alchemy engine that acts as
205
+ # a middle-man between pandas and psycopg2,
206
+ # using the same connection pool.
207
+ # There is a potential issue in that SA
208
+ # does not return connections to the
209
+ # psycopg pool when it is done. But, it
210
+ # has its own pool, and so in expected use
211
+ # it will just get one and keep reusing it.
212
+ self.sa_engine = sqlalchemy.create_engine(
213
+ "postgresql+psycopg2://", creator=self.pg_pool.getconn, pool_pre_ping=True
214
+ )
215
+
216
+ self.metadata_db_schema: str = metadata_db_schema
217
+
218
+ def query(
219
+ self,
220
+ query: str | sql.SQL | sql.Composed,
221
+ data: dict = {},
222
+ commit: bool = False,
223
+ return_result: bool = True,
224
+ ) -> tuple:
225
+ """Run a simple query on the visit sequence database.
226
+
227
+ Parameters
228
+ ----------
229
+ query : `str` or `sql.SQL`
230
+ The query to send
231
+ data : `dict`
232
+ Data to include in the query
233
+ commit : `bool`
234
+ Commit the query (e.g. for an INSERT), defaults to False
235
+ return_result : `bool`
236
+ Return the result, defaults to True
237
+
238
+ Returns
239
+ -------
240
+ result : `tuple`
241
+ The result of the query
242
+ """
243
+
244
+ connection = None
245
+ try:
246
+ # Get a connection from the pool
247
+ connection = self.pg_pool.getconn()
248
+ with connection.cursor() as cursor:
249
+ search_path_query = sql.SQL("SET search_path TO {};").format(
250
+ sql.Identifier(self.metadata_db_schema)
251
+ )
252
+ cursor.execute(search_path_query)
253
+
254
+ if len(data) > 0:
255
+ cursor.execute(query, data)
256
+ else:
257
+ cursor.execute(query)
258
+ result = cursor.fetchall() if return_result else (None,)
259
+ if commit:
260
+ connection.commit()
261
+ else:
262
+ # If a commit was not requested, make sure
263
+ # there were no alterations to the database
264
+ # in this transaction.
265
+ connection.rollback()
266
+ except Exception as e:
267
+ # If something went wrong, restore the
268
+ # connection to a stable state before
269
+ # returning it to the pool.
270
+ if connection:
271
+ try:
272
+ connection.rollback()
273
+ except Exception:
274
+ pass
275
+ LOGGER.exception("Failed to execute query: %s", e)
276
+ raise
277
+ finally:
278
+ # Make sure we always return the connection to
279
+ # the pool.
280
+ if connection:
281
+ self.pg_pool.putconn(connection)
282
+
283
+ return result
284
+
285
+ def create_schema_in_database(self) -> None:
286
+ """Create the visit sequence metadata schema in a database."""
287
+
288
+ # Creation of the production schema should be
289
+ # a one-off execution.
290
+ assert "test" in self.metadata_db_schema
291
+
292
+ # Make sure the schema does not already exist
293
+ schema_test_query = sql.SQL(
294
+ "SELECT EXISTS(SELECT * FROM information_schema.schemata WHERE schema_name = {});"
295
+ ).format(sql.Placeholder("schema"))
296
+ data = {"schema": self.metadata_db_schema}
297
+ schema_exists_return = self.query(schema_test_query, data, return_result=True)
298
+ schema_exists = schema_exists_return[0][0]
299
+ if schema_exists:
300
+ raise ValueError("Schema already exists.")
301
+
302
+ creation_sql_file = Path(__file__).parent.parent.parent / "sql" / "make_vseqmeta.sql"
303
+ with open(creation_sql_file, "r") as f:
304
+ query_template = f.read()
305
+
306
+ # Sanity check what we read.
307
+ assert "CREATE SCHEMA" in query_template
308
+
309
+ query = sql.SQL(query_template).format(
310
+ sql.Identifier(self.metadata_db_schema), sql.Identifier(self.metadata_db_schema)
311
+ )
312
+ self.query(query, {}, commit=True, return_result=False)
313
+ print("Created test database and schema ", self.metadata_db_schema)
314
+
315
+ def pd_read_sql(
316
+ self,
317
+ query_template: str,
318
+ sql_params: list[sql.Composable] | None = None,
319
+ query_params: tuple | None = None,
320
+ ) -> pd.DataFrame:
321
+ """Execute a SQL query using the internal PostgreSQL connection pool
322
+ and return the results as a pandas DataFrame.
323
+
324
+ Parameters
325
+ ----------
326
+ query_template : `str`
327
+ A query template.
328
+ sql_params : `list` [`sql.Composable`]
329
+ Elements that will be substituted into the template (e.g. table or
330
+ schema identifiers) using ``psycopg2.sql.SQL.format``.
331
+ To make type checkers happy, you may need to explictly declare
332
+ variables passed as ``list[sql.Composable]``.
333
+ query_params : `tuple`
334
+ Positional parameters that will be bound by sqlalchemy.
335
+
336
+ Returns
337
+ -------
338
+ df : `pd.DataFrame`
339
+ The result set of the query, wrapped in a DataFrame.
340
+
341
+ Notes
342
+ -----
343
+ This works around limitations in pandas when dealing with
344
+ complex datatypes in raw postgresql connections.
345
+ """
346
+
347
+ if sql_params is None:
348
+ sql_params = []
349
+ assert isinstance(sql_params, list)
350
+
351
+ if query_params is None:
352
+ query_params = tuple([])
353
+ assert isinstance(query_params, tuple)
354
+
355
+ # Pandas sometimes has trouble with postgresql when working with it
356
+ # directly, so interact with it by way of sqlalchemy.
357
+ # But, we need to use postgresql's tools for inserting identifiers
358
+ # like the schema name, followed by the sqlalchemy templating
359
+ # for actual parameters.
360
+ query = sql.SQL(query_template).format(*sql_params)
361
+ conn = None
362
+ try:
363
+ conn = self.pg_pool.getconn()
364
+ text_query = query.as_string(conn)
365
+ finally:
366
+ if conn:
367
+ self.pg_pool.putconn(conn)
368
+
369
+ with self.sa_engine.connect() as sa_conn:
370
+ df = pd.read_sql(text_query, sa_conn, params=query_params)
371
+
372
+ return df
373
+
374
+ def record_visitseq_metadata(
375
+ self,
376
+ visits: pd.DataFrame,
377
+ label: str,
378
+ telescope: str = "simonyi",
379
+ table: str = "visitseq",
380
+ url: str | None = None,
381
+ first_day_obs: date | int | str | None = None,
382
+ last_day_obs: date | int | str | None = None,
383
+ creation_time: Time | None = None,
384
+ ) -> UUID:
385
+ """Record metadata for a new visit sequence.
386
+
387
+ Parameters
388
+ ----------
389
+ visits : `pd.DataFrame`
390
+ A DataFrame of visits, with column names following those
391
+ in consdb.
392
+ label : `str`
393
+ A label for the sequence.
394
+ telescope : `str`
395
+ The telescope used, either "simonyi" or "auxtel".
396
+ Defaults to "simonyi".
397
+ table : `str`
398
+ The table in the metadata archive database in which
399
+ to insert the entry: one of ``simulations``,
400
+ ``completed``, ``mixedvisitseq``, or ``visitseq``.
401
+ url : `str`, optional
402
+ A URL for the file from thich the table of visits can
403
+ be download.
404
+ first_day_obs : `date` or `int` or `str`, optional
405
+ The first night of observations, defined according
406
+ to SITCOMTN-032 (UTC-12hrs).
407
+ last_day_obs : `date` or `int` or `str`, optional
408
+ The last day of observations, defined according
409
+ to SITCOMTN-032 (UTC-12hrs).
410
+ creation_time : `Time`, optional
411
+ The time the sequence was created, defaults no now.
412
+
413
+ Returns
414
+ -------
415
+ visitseq_uuid : `UUID`
416
+ The UUID of the new visit sequence.
417
+
418
+ Notes
419
+ -----
420
+ This method is not normally used externally, but may be
421
+ occasionally needed to add non-standard sequences of visits.
422
+ Standard simulations and completed sets of visits should be
423
+ registered with the ``record_simulation_metadata`` and
424
+ ``record_completed_metadata`` methods instead.
425
+ """
426
+
427
+ sha256 = compute_visits_sha256(visits)
428
+
429
+ columns = [
430
+ sql.Identifier("visitseq_sha256"),
431
+ sql.Identifier("visitseq_label"),
432
+ sql.Identifier("telescope"),
433
+ ]
434
+ data_placeholders = [
435
+ sql.Placeholder("visitseq_sha256"),
436
+ sql.Placeholder("visitseq_label"),
437
+ sql.Placeholder("telescope"),
438
+ ]
439
+ data: Dict[str, Any] = {"visitseq_sha256": sha256, "visitseq_label": label, "telescope": telescope}
440
+
441
+ if url is not None:
442
+ columns.append(sql.Identifier("visitseq_url"))
443
+ data_placeholders.append(sql.Placeholder("visitseq_url"))
444
+ data["visitseq_url"] = url
445
+
446
+ if first_day_obs is not None:
447
+ columns.append(sql.Identifier("first_day_obs"))
448
+ data_placeholders.append(sql.Placeholder("first_day_obs"))
449
+ data["first_day_obs"] = dayobs_to_date(first_day_obs)
450
+
451
+ if last_day_obs is not None:
452
+ columns.append(sql.Identifier("last_day_obs"))
453
+ data_placeholders.append(sql.Placeholder("last_day_obs"))
454
+ data["last_day_obs"] = dayobs_to_date(last_day_obs)
455
+
456
+ if creation_time is not None:
457
+ assert not creation_time.masked
458
+ assert creation_time.isscalar
459
+ try:
460
+ creation_datetime = creation_time.utc[0].datetime
461
+ except AttributeError:
462
+ # astropy Times, even those for which .isscalar
463
+ # is true, can sometimes need to be indexed
464
+ # by 0 to get a true scalar, and sometimes
465
+ # cannot be.
466
+ creation_utc = creation_time.utc
467
+ assert isinstance(creation_utc, Time)
468
+ creation_datetime = creation_utc.datetime
469
+ columns.append(sql.Identifier("creation_time"))
470
+ data_placeholders.append(sql.Placeholder("creation_time"))
471
+ data["creation_time"] = creation_datetime
472
+
473
+ query = sql.SQL("INSERT INTO {}.{} ({}) VALUES ({}) RETURNING visitseq_uuid").format(
474
+ sql.Identifier(self.metadata_db_schema),
475
+ sql.Identifier(table),
476
+ sql.SQL(", ").join(columns),
477
+ sql.SQL(", ").join(data_placeholders),
478
+ )
479
+
480
+ result = self.query(query, data, commit=True)[0][0]
481
+ return result
482
+
483
+ def get_visitseq_metadata(
484
+ self,
485
+ visitseq_uuid: UUID,
486
+ table: str = "visitseq",
487
+ ) -> pd.Series:
488
+ """Retrieve metadata for a visit sequence.
489
+
490
+ Parameters
491
+ ----------
492
+ visitseq_uuid : `UUID`
493
+ The UUID of the visit sequence to retrieve metadata for.
494
+ table : `str`, optional
495
+ The table in the metadata archive database in which to search.
496
+ Must be one of "visitseq", "mixedvisitseq", "completed", or
497
+ "simulations".
498
+ Defaults to "visitseq".
499
+
500
+ Returns
501
+ -------
502
+ visitseq : `pd.Series`
503
+ A Pandas Series containing the metadata for the visit sequence.
504
+
505
+ Raises
506
+ ------
507
+ ValueError
508
+ If the table is not one of "visitseq", "mixedvisitseq",
509
+ "completed", or "simulations".
510
+ """
511
+ if table not in {"visitseq", "mixedvisitseq", "completed", "simulations", "simulations_extra"}:
512
+ raise ValueError()
513
+
514
+ query_template = "SELECT * FROM {}.{} WHERE visitseq_uuid=%s"
515
+ sql_params: list[sql.Composable] = [sql.Identifier(self.metadata_db_schema), sql.Identifier(table)]
516
+ query_params = (visitseq_uuid,)
517
+ visitseq = self.pd_read_sql(query_template, sql_params, query_params).iloc[0, :]
518
+ return visitseq
519
+
520
+ def set_visitseq_url(self, visitseq_uuid: UUID, visitseq_url: str) -> None:
521
+ """Update the URL for the visits file for a visit sequence.
522
+
523
+ Parameters
524
+ ----------
525
+ visitseq_uuid : `UUID`
526
+ The UUID of the visit sequence to update.
527
+ visitseq_url : `str`
528
+ The new URL for the visit sequence.
529
+
530
+ Raises
531
+ ------
532
+ `ValueError`
533
+ If the table is not one of "visitseq", "mixedvisitseq",
534
+ "completed", or "simulations".
535
+ """
536
+ table = self._find_visitseq_table(visitseq_uuid)
537
+ query = sql.SQL("UPDATE {}.{} SET visitseq_url={} WHERE visitseq_uuid={} RETURNING *;").format(
538
+ sql.Identifier(self.metadata_db_schema),
539
+ sql.Identifier(table),
540
+ sql.Placeholder("visitseq_url"),
541
+ sql.Placeholder("visitseq_uuid"),
542
+ )
543
+ data = {"visitseq_url": visitseq_url, "visitseq_uuid": visitseq_uuid}
544
+ self.query(query, data, return_result=False, commit=True)
545
+
546
+ def get_visitseq_url(self, visitseq_uuid: UUID) -> str:
547
+ """Retrieve the URL for the file with the visits in a visit sequence.
548
+
549
+ Parameters
550
+ ----------
551
+ visitseq_uuid : `UUID`
552
+ The UUID of the visit sequence to search for.
553
+
554
+ Returns
555
+ -------
556
+ url : `str`
557
+ The URL for the visit sequence.
558
+
559
+ Raises
560
+ ------
561
+ `ValueError`
562
+ If no URL is found for the visit sequence.
563
+ """
564
+ query = sql.SQL("SELECT visitseq_url FROM {}.visitseq WHERE visitseq_uuid={}").format(
565
+ sql.Identifier(self.metadata_db_schema),
566
+ sql.Placeholder("visitseq_uuid"),
567
+ )
568
+ data = {"visitseq_uuid": visitseq_uuid}
569
+ response = self.query(query, data)
570
+ if len(response) < 1:
571
+ raise ValueError(f"No URL for {visitseq_uuid} found")
572
+ assert len(response) == 1, f"Datatabase has too many visit sequinces with UUID={visitseq_uuid}"
573
+ return response[0][0]
574
+
575
+ def record_simulation_metadata(
576
+ self,
577
+ visits: pd.DataFrame,
578
+ label: str,
579
+ telescope: str = "simonyi",
580
+ url: str | None = None,
581
+ first_day_obs: date | int | str | None = None,
582
+ last_day_obs: date | int | str | None = None,
583
+ creation_time: Time | None = None,
584
+ scheduler_version: str | None = None,
585
+ config_url: str | None = None,
586
+ conda_env_sha256: bytes | None = None,
587
+ parent_visitseq_uuid: UUID | None = None,
588
+ sim_runner_kwargs: dict | None = None,
589
+ parent_last_day_obs: str | date | int | None = None,
590
+ ) -> UUID:
591
+ """Record metadata for a new simulation sequence.
592
+
593
+ Parameters
594
+ ----------
595
+ visits : pd.DataFrame
596
+ A DataFrame of visits, with column names following those
597
+ in consdb.
598
+ label : str
599
+ A label for the sequence.
600
+ telescope : `str`
601
+ The telescope used, either "simonyi" or "auxtel".
602
+ Defaults to "simonyi".
603
+ url : `str`, optional
604
+ A URL for the file from thich the table of visits can
605
+ be download.
606
+ first_day_obs : `date` or `int` or `str`, optional
607
+ The first night of observations, defined according
608
+ to SITCOMTN-032 (UTC-12hrs).
609
+ last_day_obs : `date` or `int` or `str`, optional
610
+ The last day of observations, defined according
611
+ to SITCOMTN-032 (UTC-12hrs).
612
+ creation_time : `Time`, optional
613
+ The time the sequence was created, defaults to now.
614
+ scheduler_version : `str`, optional
615
+ Version of ``rubin_scheduler`` used to run the simulation,
616
+ by default None.
617
+ config_url : `str`, optional
618
+ URL for the config script, perhaps on github,
619
+ by default `None`.
620
+ conda_env_sha256 : `bytes`, optional
621
+ SHA256 hash of output of ``conda list --json``, by default None.
622
+ parent_visitseq_uuid : `UUID`, optional
623
+ UUID of visitseq loaded into scheduler before running,
624
+ by default `None`.
625
+ sim_runner_kwargs : `dict`, optional
626
+ Arguments to ``sim_runner`` as a `dict`, by default `None`.
627
+ parent_last_day_obs : `str`, optional
628
+ day_obs of last visit loaded into scheduler before running,
629
+ by default `None`.
630
+
631
+ Returns
632
+ -------
633
+ visitseq_uuid : UUID
634
+ The UUID of the new visit sequence.
635
+ """
636
+ # I would have preferred to use kwargs here, but was not
637
+ # able to get type checking happy with it.
638
+ visitseq_uuid = self.record_visitseq_metadata(
639
+ visits,
640
+ label,
641
+ telescope=telescope,
642
+ table="simulations",
643
+ url=url,
644
+ first_day_obs=first_day_obs,
645
+ last_day_obs=last_day_obs,
646
+ creation_time=creation_time,
647
+ )
648
+
649
+ def make_set_clause(column: str) -> sql.Composed:
650
+ set_clause = sql.SQL("{} = {}").format(sql.Identifier(column), sql.Placeholder(column))
651
+ return set_clause
652
+
653
+ set_clauses = []
654
+ data: Dict[str, Any] = {}
655
+ if scheduler_version is not None:
656
+ set_clauses.append(make_set_clause("scheduler_version"))
657
+ data["scheduler_version"] = scheduler_version
658
+
659
+ if config_url is not None:
660
+ set_clauses.append(make_set_clause("config_url"))
661
+ data["config_url"] = config_url
662
+
663
+ if conda_env_sha256 is not None:
664
+ set_clauses.append(make_set_clause("conda_env_sha256"))
665
+ data["conda_env_sha256"] = conda_env_sha256
666
+
667
+ if parent_visitseq_uuid is not None:
668
+ set_clauses.append(make_set_clause("parent_visitseq_uuid"))
669
+ data["parent_visitseq_uuid"] = str(parent_visitseq_uuid)
670
+
671
+ if sim_runner_kwargs is not None:
672
+ sim_runner_munged_kwargs = {}
673
+ for key in sim_runner_kwargs:
674
+ try:
675
+ # Throws a TypeError if it cannot convert it to json
676
+ json_result = json.dumps(sim_runner_kwargs[key])
677
+ json_failed = len(json_result) > JSON_DUMP_LIMIT
678
+ except TypeError:
679
+ # Cannot convert to json, just store a string
680
+ # representation instead.
681
+ json_failed = True
682
+
683
+ # if we can save an argument as serealized json in reasonable
684
+ # space, do, otherwise store a (possibly truncated) result of
685
+ # repr.
686
+ sim_runner_munged_kwargs[key] = (
687
+ ("Not json serializable", repr(sim_runner_kwargs[key])[:JSON_DUMP_LIMIT])
688
+ if json_failed
689
+ else sim_runner_kwargs[key]
690
+ )
691
+
692
+ set_clauses.append(make_set_clause("sim_runner_kwargs"))
693
+ data["sim_runner_kwargs"] = psycopg2.extras.Json(sim_runner_munged_kwargs)
694
+
695
+ if parent_last_day_obs is not None:
696
+ set_clauses.append(make_set_clause("parent_last_day_obs"))
697
+ data["parent_last_day_obs"] = parent_last_day_obs
698
+
699
+ num_columns_to_update = len(set_clauses)
700
+
701
+ if num_columns_to_update > 0:
702
+ query = sql.SQL("UPDATE {}.simulations SET {} WHERE visitseq_uuid={} RETURNING *;").format(
703
+ sql.Identifier(self.metadata_db_schema),
704
+ sql.SQL(", ").join(set_clauses),
705
+ sql.Placeholder("visitseq_uuid"),
706
+ )
707
+ data["visitseq_uuid"] = visitseq_uuid
708
+
709
+ conn = None
710
+ try:
711
+ conn = self.pg_pool.getconn()
712
+ cursor = conn.cursor()
713
+ cursor.execute(query, data)
714
+ result = cursor.fetchall()
715
+
716
+ # Be extra cautious, and check that everything looks
717
+ # reasonable before commiting the update.
718
+ assert len(result) == 1
719
+ cursor.execute("COMMIT;")
720
+ finally:
721
+ if conn:
722
+ self.pg_pool.putconn(conn)
723
+
724
+ return visitseq_uuid
725
+
726
+ def update_visitseq_metadata(self, visitseq_uuid: UUID, field: str, value: object) -> None:
727
+ """Update a single metadata field for a visit sequence.
728
+ Automatically finds the table in which to make the update.
729
+
730
+ Parameters
731
+ ----------
732
+ visitseq_uuid : `UUID`
733
+ The unique identifier of the visit sequence to update.
734
+ field : `str`
735
+ The name of the column in the metadata table to modify.
736
+ value : `object`
737
+ The new value to assign to ``field``. The type must be
738
+ compatible with the column's database type.
739
+ """
740
+ table = self._find_visitseq_table(visitseq_uuid)
741
+ query = sql.SQL("UPDATE {}.{} SET {}={} WHERE visitseq_uuid={};").format(
742
+ sql.Identifier(self.metadata_db_schema),
743
+ sql.Identifier(table),
744
+ sql.Identifier(field),
745
+ sql.Placeholder("value"),
746
+ sql.Placeholder("visitseq_uuid"),
747
+ )
748
+ data = {"value": value, "visitseq_uuid": visitseq_uuid}
749
+ self.query(query, data, return_result=False, commit=True)
750
+
751
+ def record_completed_metadata(
752
+ self,
753
+ visits: pd.DataFrame,
754
+ label: str,
755
+ telescope: str = "simonyi",
756
+ url: str | None = None,
757
+ first_day_obs: date | int | str | None = None,
758
+ last_day_obs: date | int | str | None = None,
759
+ creation_time: Time | None = None,
760
+ query: str | None = None,
761
+ ) -> UUID:
762
+ """Record metadata for a sequence of visits queried from
763
+ the consdb.
764
+
765
+ Parameters
766
+ ----------
767
+ visits : `pd.DataFrame`
768
+ A DataFrame of visits, with column names following those
769
+ in consdb.
770
+ label : `str`
771
+ A label for the sequence.
772
+ telescope : `str`
773
+ The telescope used, either "simonyi" or "auxtel".
774
+ Defaults to "simonyi".
775
+ url : `str`, optional
776
+ A URL for the file from thich the table of visits can
777
+ be download.
778
+ first_day_obs : `date` or `int` or `str`, optional
779
+ The first night of observations, defined according
780
+ to SITCOMTN-032 (UTC-12hrs).
781
+ last_day_obs : `date` or `int` or `str`, optional
782
+ The last day of observations, defined according
783
+ to SITCOMTN-032 (UTC-12hrs).
784
+ creation_time : `Time`, optional
785
+ The time the sequence was created, defaults no now.
786
+ query : `str`, optional
787
+ The query to the consdb used to get the completed
788
+ visits, by default None.
789
+
790
+ Returns
791
+ -------
792
+ visitseq_uuid : `UUID`
793
+ The UUID of the new visit sequence.
794
+ """
795
+
796
+ # I would have preferred to use kwargs here, but was not
797
+ # able to get type checking happy with it.
798
+ visitseq_uuid = self.record_visitseq_metadata(
799
+ visits,
800
+ label,
801
+ telescope=telescope,
802
+ table="completed",
803
+ url=url,
804
+ first_day_obs=first_day_obs,
805
+ last_day_obs=last_day_obs,
806
+ creation_time=creation_time,
807
+ )
808
+
809
+ if query is not None:
810
+ composed_query = sql.SQL(
811
+ "UPDATE {}.completed SET query={} WHERE visitseq_uuid={} RETURNING *"
812
+ ).format(
813
+ sql.Identifier(self.metadata_db_schema),
814
+ sql.Placeholder("query"),
815
+ sql.Placeholder("visitseq_uuid"),
816
+ )
817
+ data = {"query": query, "visitseq_uuid": visitseq_uuid}
818
+
819
+ conn = None
820
+ try:
821
+ conn = self.pg_pool.getconn()
822
+ cursor = conn.cursor()
823
+ cursor.execute(composed_query, data)
824
+ result = cursor.fetchall()
825
+
826
+ # Be extra cautious, and check that everything looks
827
+ # reasonable before commiting the update.
828
+ assert len(result) == 1
829
+ cursor.execute("COMMIT;")
830
+ finally:
831
+ if conn:
832
+ self.pg_pool.putconn(conn)
833
+
834
+ return visitseq_uuid
835
+
836
+ def record_mixed_metadata(
837
+ self,
838
+ visits: pd.DataFrame,
839
+ label: str,
840
+ last_early_day_obs: date | int | str,
841
+ first_late_day_obs: date | int | str,
842
+ early_parent_uuid: UUID,
843
+ late_parent_uuid: UUID,
844
+ telescope: str = "simonyi",
845
+ url: str | None = None,
846
+ first_day_obs: date | int | str | None = None,
847
+ last_day_obs: date | int | str | None = None,
848
+ creation_time: Time | None = None,
849
+ ) -> UUID:
850
+ """Record metadata for a new mixed visit sequence.
851
+
852
+ Parameters
853
+ ----------
854
+ visits : `pd.DataFrame`
855
+ A DataFrame of visits, with column names following those
856
+ in consdb.
857
+ label : `str`
858
+ A label for the sequence.
859
+ last_early_day_obs : `date` or `int` or `str`
860
+ The last day obs of the early parent.
861
+ first_late_day_obs : `date` or `int` or `str`
862
+ The first day obs of the late parent.
863
+ early_parent_uuid : `UUID`
864
+ The UUID of the early parent.
865
+ late_parent_uuid : `UUID`
866
+ The UUID of the late parent.
867
+ telescope : `str`, optional
868
+ The telescope used, either "simonyi" or "auxtel".
869
+ Defaults to "simonyi".
870
+ first_day_obs : `date` or `int` or `str` or `None`, optional
871
+ The first night of observations, defined according
872
+ to SITCOMTN-032 (UTC-12hrs).
873
+ last_day_obs : `date` or `int` or `str` or `None`, optional
874
+ The last day of observations, defined according
875
+ to SITCOMTN-032 (UTC-12hrs).
876
+ creation_time : `Time` or `None`, optional
877
+ The time the sequence was created, defaults to now.
878
+
879
+ Returns
880
+ -------
881
+ visitseq_uuid : `UUID`
882
+ The UUID of the new visit sequence.
883
+ """
884
+ visitseq_uuid = self.record_visitseq_metadata(
885
+ visits,
886
+ label,
887
+ telescope=telescope,
888
+ table="mixedvisitseq",
889
+ url=url,
890
+ first_day_obs=first_day_obs,
891
+ last_day_obs=last_day_obs,
892
+ creation_time=creation_time,
893
+ )
894
+
895
+ update_query = sql.SQL(
896
+ """UPDATE {}.mixedvisitseq
897
+ SET last_early_day_obs={},
898
+ first_late_day_obs={},
899
+ early_parent_uuid={},
900
+ late_parent_uuid={}\
901
+ WHERE visitseq_uuid={} RETURNING *
902
+ """
903
+ ).format(
904
+ sql.Identifier(self.metadata_db_schema),
905
+ sql.Placeholder("last_early_day_obs"),
906
+ sql.Placeholder("first_late_day_obs"),
907
+ sql.Placeholder("early_parent_uuid"),
908
+ sql.Placeholder("late_parent_uuid"),
909
+ sql.Placeholder("visitseq_uuid"),
910
+ )
911
+
912
+ data = {
913
+ "last_early_day_obs": dayobs_to_date(last_early_day_obs),
914
+ "first_late_day_obs": dayobs_to_date(first_late_day_obs),
915
+ "early_parent_uuid": early_parent_uuid,
916
+ "late_parent_uuid": late_parent_uuid,
917
+ "visitseq_uuid": visitseq_uuid,
918
+ }
919
+
920
+ conn = None
921
+ try:
922
+ conn = self.pg_pool.getconn()
923
+ cursor = conn.cursor()
924
+ cursor.execute(update_query, data)
925
+ result = cursor.fetchall()
926
+
927
+ # Be extra cautious, and check that everything looks
928
+ # reasonable before commiting the update.
929
+ assert len(result) == 1
930
+ cursor.execute("COMMIT;")
931
+ finally:
932
+ if conn:
933
+ self.pg_pool.putconn(conn)
934
+
935
+ return visitseq_uuid
936
+
937
+ def is_tagged(self, visitseq_uuid: UUID, tag: str) -> bool:
938
+ """Return whether a visit sequence is tagged with a given tag.
939
+
940
+ Parameters
941
+ ----------
942
+ visitseq_uuid : `UUID`
943
+ The UUID of the visit sequence.
944
+ tag : `str`
945
+ The tag to check for.
946
+
947
+ Returns
948
+ -------
949
+ is_tagged : `bool`
950
+ True if the visit sequence is tagged with the given tag,
951
+ False otherwise.
952
+ """
953
+ query = sql.SQL("SELECT COUNT(*)>=1 FROM {}.tags WHERE visitseq_uuid={} AND TAG={}").format(
954
+ sql.Identifier(self.metadata_db_schema), sql.Placeholder("visitseq_uuid"), sql.Placeholder("tag")
955
+ )
956
+ data = {"visitseq_uuid": visitseq_uuid, "tag": tag}
957
+ is_tagged = self.query(query, data)[0][0] > 0
958
+ return is_tagged
959
+
960
+ def tag(self, visitseq_uuid: UUID, *tags: str) -> None:
961
+ """Tag a visit sequence with one or more tags.
962
+
963
+ Parameters
964
+ ----------
965
+ visitseq_uuid : `UUID`
966
+ The UUID of the visit sequence.
967
+ *tags : `str`
968
+ One or more tags to add to the visit sequence.
969
+ """
970
+ query = sql.SQL("INSERT INTO {}.tags (visitseq_uuid, tag) VALUES ({}, {})").format(
971
+ sql.Identifier(self.metadata_db_schema), sql.Placeholder("visitseq_uuid"), sql.Placeholder("tag")
972
+ )
973
+ data: Dict[str, Any] = {"visitseq_uuid": visitseq_uuid}
974
+ for tag in tags:
975
+ if not self.is_tagged(visitseq_uuid, tag):
976
+ data["tag"] = tag
977
+ self.query(query, data, commit=True, return_result=False)
978
+
979
+ def untag(self, visitseq_uuid: UUID, tag: str) -> None:
980
+ """Untag a visit sequence with a given tag.
981
+
982
+ Parameters
983
+ ----------
984
+ visitseq_uuid : `UUID`
985
+ The UUID of the visit sequence.
986
+ tag : `str`
987
+ The tag to remove.
988
+
989
+ Raises
990
+ ------
991
+ `ValueError`
992
+ If the visit sequence is not tagged with the given tag.
993
+ """
994
+ if not self.is_tagged(visitseq_uuid, tag):
995
+ raise ValueError(f"Visit sequence {visitseq_uuid} is not tagged with {tag}")
996
+
997
+ query = sql.SQL("DELETE FROM {}.tags WHERE visitseq_uuid={} AND tag={}").format(
998
+ sql.Identifier(self.metadata_db_schema),
999
+ sql.Placeholder("visitseq_uuid"),
1000
+ sql.Placeholder("tag"),
1001
+ )
1002
+ data = {"visitseq_uuid": visitseq_uuid, "tag": tag}
1003
+ self.query(query, data, commit=True, return_result=False)
1004
+
1005
+ def comment(self, visitseq_uuid: UUID, comment: str, author: str | None = None) -> None:
1006
+ """Attach a comment to a visit sequence in the
1007
+ metadata database..
1008
+
1009
+ Parameters
1010
+ ----------
1011
+ visitseq_uuid: `uuid.UUID`
1012
+ The UUID of the visit sequence to comment on.
1013
+ comment : `str`
1014
+ The comment to attach.
1015
+ author : `str`, optional
1016
+ The author of the comment.
1017
+ Defaults to `None`.
1018
+ """
1019
+ comment_utc = Time.now().utc
1020
+ # mypy cannot figure out that comment_utc cannot be masked
1021
+ # so assert that it is not as a hint to it.
1022
+ assert isinstance(comment_utc, Time)
1023
+ comment_time = comment_utc.datetime
1024
+ query = ""
1025
+ data = {"visitseq_uuid": visitseq_uuid, "comment_time": comment_time, "comment": comment}
1026
+ if author is None:
1027
+ query = sql.SQL(
1028
+ "INSERT INTO {}.comments (visitseq_uuid, comment_time, comment) VALUES ({}, {}, {})"
1029
+ ).format(
1030
+ sql.Identifier(self.metadata_db_schema),
1031
+ sql.Placeholder("visitseq_uuid"),
1032
+ sql.Placeholder("comment_time"),
1033
+ sql.Placeholder("comment"),
1034
+ )
1035
+ else:
1036
+ query = sql.SQL(
1037
+ "INSERT INTO {}.comments (visitseq_uuid, comment_time, author, comment)"
1038
+ + " VALUES ({}, {}, {}, {})"
1039
+ ).format(
1040
+ sql.Identifier(self.metadata_db_schema),
1041
+ sql.Placeholder("visitseq_uuid"),
1042
+ sql.Placeholder("comment_time"),
1043
+ sql.Placeholder("author"),
1044
+ sql.Placeholder("comment"),
1045
+ )
1046
+ data["author"] = author
1047
+
1048
+ self.query(query, data, commit=True, return_result=False)
1049
+
1050
+ def get_comments(self, visitseq_uuid: UUID) -> pd.DataFrame:
1051
+ """Retrieve all comments attached to a specific visit sequence.
1052
+
1053
+ Parameters
1054
+ ----------
1055
+ visitseq_uuid : `UUID`
1056
+ The UUID of the visit sequence whose comments should be fetched.
1057
+
1058
+ Returns
1059
+ -------
1060
+ comments: `pd.DataFrame`
1061
+ A DataFrame containing the comments for the requested visit
1062
+ sequence. The DataFrame has the following columns:
1063
+
1064
+ ``"visitseq_uuid"``
1065
+ The ID of the visit sequence.
1066
+ ``"comment_time"``
1067
+ Timestamp of the comment.
1068
+ ``"author"``
1069
+ Name of the user who added the comment; may be ``None``.
1070
+ ``"comment"``
1071
+ The comment text.
1072
+
1073
+ If the visit sequence has no comments, an empty DataFrame with the
1074
+ above column names is returned.
1075
+
1076
+ """
1077
+ query_template = "SELECT * FROM {}.comments WHERE visitseq_uuid = %s"
1078
+ sql_params: list[sql.Composable] = [sql.Identifier(self.metadata_db_schema)]
1079
+ query_params = (visitseq_uuid,)
1080
+ comments = self.pd_read_sql(query_template, sql_params, query_params)
1081
+
1082
+ # sqlalchemy seems to mess up the dtypes. Fix them.
1083
+ comments["comment"] = comments["comment"].astype("string")
1084
+
1085
+ return comments
1086
+
1087
+ def register_file(
1088
+ self,
1089
+ visitseq_uuid: UUID,
1090
+ file_type: str,
1091
+ file_sha256: bytes,
1092
+ location: str | ResourcePath,
1093
+ update: bool = False,
1094
+ ) -> None:
1095
+ """Register a file in the visit sequence metadata database.
1096
+
1097
+ Parameters
1098
+ ----------
1099
+ visitseq_uuid : `UUID`
1100
+ The UUID of the visit sequence with which the file should be
1101
+ associated.
1102
+ file_type : `str`
1103
+ The handle for the file type.
1104
+ The ``"visits"`` file type is not allowed
1105
+ because visit tables are recorded using `set_visitseq_url`.
1106
+ file_sha256 : `bytes`
1107
+ The hash of the file contents.
1108
+ location : `str` or `lsst.resources.ResourcePath`
1109
+ The location of the file to be registered.
1110
+ update : `bool`, optional
1111
+ If ``True`` and a record for the same ``visitseq_uuid`` and
1112
+ ``file_type`` already exists, the existing row will be updated with
1113
+ the new SHA‑256 hash and URL. If ``False`` and a record already
1114
+ exists a ``ValueError`` is raised.
1115
+
1116
+ Raises
1117
+ ------
1118
+ `ValueError`
1119
+ If ``file_type`` is ``"visits"`` (use `set_visitseq_url` instead),
1120
+ or if ``update`` is ``False`` and a record already exists for the
1121
+ given ``visitseq_uuid`` and ``file_type``.
1122
+ `TypeError`
1123
+ If ``location`` is neither a string nor a `ResourcePath`.
1124
+ """
1125
+ if file_type == "visits":
1126
+ raise ValueError("Use set_visitseq_url to register sets of visits themselves")
1127
+
1128
+ file_url: str = ""
1129
+ match location:
1130
+ case ResourcePath():
1131
+ file_url = location.geturl()
1132
+ case str():
1133
+ file_url = location
1134
+ case _:
1135
+ raise ValueError("Unrecognised location type")
1136
+
1137
+ # Check whether the file is already registered
1138
+ already_exists = False
1139
+ try:
1140
+ self.get_file_url(visitseq_uuid, file_type)
1141
+ already_exists = True
1142
+ except ValueError:
1143
+ already_exists = False
1144
+
1145
+ if already_exists:
1146
+ if not update:
1147
+ raise ValueError("A file of type {file_type} is already registered for {visitseq_uuid}")
1148
+
1149
+ query = sql.SQL(
1150
+ "UPDATE {}.files SET file_sha256={}, file_url={} WHERE visitseq_uuid={} AND file_type={}"
1151
+ ).format(
1152
+ sql.Identifier(self.metadata_db_schema),
1153
+ sql.Placeholder("file_sha256"),
1154
+ sql.Placeholder("file_url"),
1155
+ sql.Placeholder("visitseq_uuid"),
1156
+ sql.Placeholder("file_type"),
1157
+ )
1158
+ else:
1159
+ query = sql.SQL(
1160
+ "INSERT INTO {}.files (visitseq_uuid, file_type, file_sha256, file_url)"
1161
+ + " VALUES ({}, {}, {}, {})"
1162
+ ).format(
1163
+ sql.Identifier(self.metadata_db_schema),
1164
+ sql.Placeholder("visitseq_uuid"),
1165
+ sql.Placeholder("file_type"),
1166
+ sql.Placeholder("file_sha256"),
1167
+ sql.Placeholder("file_url"),
1168
+ )
1169
+
1170
+ data = {
1171
+ "visitseq_uuid": visitseq_uuid,
1172
+ "file_type": file_type,
1173
+ "file_sha256": file_sha256,
1174
+ "file_url": file_url,
1175
+ }
1176
+
1177
+ self.query(query, data, commit=True, return_result=False)
1178
+
1179
+ def get_file_url(self, visitseq_uuid: UUID, file_type: str) -> str:
1180
+ """Return the URL for a registered file of a given type and
1181
+ visit sequence UUID.
1182
+
1183
+ Parameters
1184
+ ----------
1185
+ visitseq_uuid : `UUID`
1186
+ The UUID of the visit sequence with which the desired
1187
+ file is associated.
1188
+ file_type : `str`
1189
+ Handle for the file type.
1190
+
1191
+ Returns
1192
+ -------
1193
+ url : `str`
1194
+ The URL pointing to the requested file.
1195
+ """
1196
+ if file_type == "visits":
1197
+ # It's as easy to just do it as it is to raise
1198
+ # an exception
1199
+ return self.get_visitseq_url(visitseq_uuid)
1200
+
1201
+ query = sql.SQL("SELECT file_url FROM {}.files WHERE visitseq_uuid={} AND file_type={}").format(
1202
+ sql.Identifier(self.metadata_db_schema),
1203
+ sql.Placeholder("visitseq_uuid"),
1204
+ sql.Placeholder("file_type"),
1205
+ )
1206
+ data = {"visitseq_uuid": visitseq_uuid, "file_type": file_type}
1207
+ result = self.query(query, data, return_result=True)
1208
+ if len(result) < 1:
1209
+ raise ValueError(f"No URLs found for {file_type} for visitseq {visitseq_uuid}")
1210
+ if len(result) > 1:
1211
+ raise ValueError(f"Too many URLs found for {file_type} for visitseq {visitseq_uuid}!")
1212
+
1213
+ url = result[0][0]
1214
+ return url
1215
+
1216
+ def get_file_sha256(self, visitseq_uuid: UUID, file_type: str) -> bytes:
1217
+ """Retrieve the SHA‑256 digest of a registered file.
1218
+
1219
+ Parameters
1220
+ ----------
1221
+ visitseq_uuid : `UUID`
1222
+ The UUID of the visit sequence the file is associated with.
1223
+ file_type : `str`
1224
+ Handle for the file type.
1225
+
1226
+ Returns
1227
+ -------
1228
+ file_sha256 : `bytes`
1229
+ The raw SHA‑256 digest of the file contents. The value
1230
+ is returned as a 32‑byte object (the output of
1231
+ ``hashlib.sha256``).
1232
+ """
1233
+
1234
+ query = sql.SQL("SELECT file_sha256 FROM {}.files WHERE visitseq_uuid={} AND file_type={}").format(
1235
+ sql.Identifier(self.metadata_db_schema),
1236
+ sql.Placeholder("visitseq_uuid"),
1237
+ sql.Placeholder("file_type"),
1238
+ )
1239
+ data = {"visitseq_uuid": visitseq_uuid, "file_type": file_type}
1240
+ result = self.query(query, data, return_result=True)
1241
+ if len(result) < 1:
1242
+ raise ValueError(f"No URLs found for {file_type} for visitseq {visitseq_uuid}")
1243
+ if len(result) > 1:
1244
+ raise ValueError(f"Too many URLs found for {file_type} for visitseq {visitseq_uuid}!")
1245
+
1246
+ file_sha256 = result[0][0].tobytes()
1247
+ return file_sha256
1248
+
1249
+ def insert_nightly_stats(self, visitseq_uuid: UUID, nightly_stats: pd.DataFrame) -> None:
1250
+ """Instert by-night statistics into associoted with
1251
+ a visit sequence into the visit sequence metadata database.
1252
+
1253
+ Parameters
1254
+ ----------
1255
+ visitseq_uuid : `UUID`
1256
+ The UUID of the visit sequence whose statistics are being
1257
+ recorded.
1258
+ stats_df : `pd.DataFrame`
1259
+ A DataFrame containing the nightly statistics that were
1260
+ written to the database. The columns are:
1261
+
1262
+ ``"day_obs"``
1263
+ The observation day.
1264
+ ``"value_name"``
1265
+ The value for which statistics were computed.
1266
+ ``"p05"``
1267
+ 5th percentile.
1268
+ ``"q1"``
1269
+ 25th percentile (first quartile).
1270
+ ``"median"``
1271
+ 50th percentile (median).
1272
+ ``"q3"``
1273
+ 75th percentile (third quartile).
1274
+ ``"p95"``
1275
+ 95th percentile.
1276
+ ``"visitseq_uuid"``
1277
+ The UUID of the sequence.
1278
+ ``"accumulated"``
1279
+ Always ``False`` for data written by
1280
+ this method.
1281
+ """
1282
+ # We are adding a column, but do not want
1283
+ # to change the original df, so
1284
+ # create a copy.
1285
+ stats_df = nightly_stats.copy()
1286
+ stats_df["visitseq_uuid"] = visitseq_uuid
1287
+
1288
+ # pandas to_sql fails with the native psycopg2, only
1289
+ # works with sqlalchemy
1290
+ engine = sqlalchemy.create_engine("postgresql+psycopg2://", creator=self.pg_pool.getconn)
1291
+ with engine.connect() as conn:
1292
+ num_rows_added = stats_df.to_sql(
1293
+ "nightly_stats", conn, schema=self.metadata_db_schema, if_exists="append", index=False
1294
+ )
1295
+ assert num_rows_added == len(stats_df)
1296
+
1297
+ def query_nightly_stats(self, visitseq_uuid: UUID) -> pd.DataFrame:
1298
+ """Query the visit sequence metadata database for
1299
+ nightly statistics.
1300
+
1301
+ Parameters
1302
+ ----------
1303
+ visitseq_uuid : `UUID`
1304
+ The UUID of the visit sequence whose statistics are being
1305
+ recorded.
1306
+
1307
+ Returns
1308
+ -------
1309
+ stats_df : `pd.DataFrame`
1310
+ A table of nightly statistics.
1311
+ """
1312
+ query_template = "SELECT * FROM {}.nightly_stats WHERE visitseq_uuid=%s"
1313
+ sql_params: list[sql.Composable] = [sql.Identifier(self.metadata_db_schema)]
1314
+ query_params = (visitseq_uuid,)
1315
+ stats_df = self.pd_read_sql(query_template, sql_params, query_params)
1316
+ return stats_df
1317
+
1318
+ def conda_env_is_saved(self, conda_env_hash: bytes) -> bool:
1319
+ """Check whether a conda environment with the given hash is already
1320
+ stored in the database.
1321
+
1322
+ Parameters
1323
+ ----------
1324
+ conda_env_hash : `bytes`
1325
+ The SHA‑256 digest of the conda environment JSON representation.
1326
+ This value is expected to be the same output returned by
1327
+ `compute_conda_env`.
1328
+
1329
+ Returns
1330
+ -------
1331
+ env_exists : `bool`
1332
+ ``True`` if a row with the supplied hash exists in the
1333
+ ``conda_env`` table; ``False`` otherwise.
1334
+ """
1335
+ query = sql.SQL("SELECT EXISTS(SELECT true from {}.conda_env WHERE conda_env_hash={})").format(
1336
+ sql.Identifier(self.metadata_db_schema), sql.Placeholder("conda_env_hash")
1337
+ )
1338
+ data = {"conda_env_hash": conda_env_hash}
1339
+ result = self.query(query, data, commit=False, return_result=True)
1340
+ env_exists = result[0][0]
1341
+ assert isinstance(env_exists, bool)
1342
+ return env_exists
1343
+
1344
+ def record_conda_env(
1345
+ self, conda_env_hash: bytes | None = None, conda_env_json: str | None = None
1346
+ ) -> bytes:
1347
+ """Record the current Conda environment in the database.
1348
+
1349
+ Parameters
1350
+ ----------
1351
+ conda_env_hash : `bytes` or `None`
1352
+ The SHA‑256 digest of the Conda environment JSON
1353
+ representation. ``None`` (the default)
1354
+ uses the current conda environment.
1355
+ conda_env_json : `str` or `None`
1356
+ The json representing the conda environment.
1357
+ ``None`` (the default) uses the current
1358
+ conda environment.
1359
+ """
1360
+ if conda_env_hash is None != conda_env_json is None:
1361
+ raise ValueError("conda_env_hash and conda_env_json must both be set, or neither.")
1362
+
1363
+ if conda_env_hash is None:
1364
+ conda_env_hash, conda_env_json = compute_conda_env()
1365
+
1366
+ assert isinstance(conda_env_hash, bytes)
1367
+ assert isinstance(conda_env_json, str)
1368
+
1369
+ if self.conda_env_is_saved(conda_env_hash):
1370
+ warnings.warn("Conda env with hash already exists, not saving again.")
1371
+ return conda_env_hash
1372
+
1373
+ query = sql.SQL("INSERT INTO {}.conda_env (conda_env_hash, conda_env) VALUES ({}, {})").format(
1374
+ sql.Identifier(self.metadata_db_schema),
1375
+ sql.Placeholder("conda_env_hash"),
1376
+ sql.Placeholder("conda_env"),
1377
+ )
1378
+ data = {"conda_env_hash": conda_env_hash, "conda_env": conda_env_json}
1379
+ self.query(query, data, commit=True, return_result=False)
1380
+ return conda_env_hash
1381
+
1382
+ def _find_visitseq_table(self, visitseq_uuid: UUID) -> str:
1383
+ # Figure out which child table the visitseq_uuid is in by
1384
+ # querying each in turn.
1385
+
1386
+ for table in ("mixedvisitseq", "completed", "simulations", "visitseq"):
1387
+ query = sql.SQL("SELECT COUNT(*) FROM {}.{} WHERE visitseq_uuid={};").format(
1388
+ sql.Identifier(self.metadata_db_schema),
1389
+ sql.Identifier(table),
1390
+ sql.Placeholder("visitseq_uuid"),
1391
+ )
1392
+ data = {"visitseq_uuid": visitseq_uuid}
1393
+ result = self.query(query, data)
1394
+ assert isinstance(result[0][0], int)
1395
+ num_rows: int = result[0][0]
1396
+ if num_rows > 0:
1397
+ return table
1398
+
1399
+ raise ValueError("Visit sequence not found.")
1400
+
1401
+ def sims_on_nights(
1402
+ self,
1403
+ first_day_obs: str | date | int | None = None,
1404
+ last_day_obs: str | date | int | None = None,
1405
+ tags: tuple[str, ...] = ("prenight", "ideal", "nominal"),
1406
+ telescope: str = "simonyi",
1407
+ max_simulation_age: int = 2,
1408
+ ) -> pd.DataFrame:
1409
+ """Return a table of simulations that cover a given night range.
1410
+
1411
+ Parameters
1412
+ ----------
1413
+ first_day_obs : `str` | `date` | `int` | `None`, optional
1414
+ The first observation day of the window for which simulations are
1415
+ requested. It can be specified as a `datetime.date` object, an
1416
+ ISO‑formatted string (e.g. ``'2025-12-01'``), or an integer in
1417
+ SMTN‑032 format (e.g. ``20251201``). If ``None`` (the default),
1418
+ the method uses the current dayobs.
1419
+ last_day_obs : `str` | `date` | `int` | `None`, optional
1420
+ The last observation day of the window. It accepts the same
1421
+ types as ``first_day_obs``. If ``None`` the same value as
1422
+ ``first_day_obs`` is used, giving a single‑night query.
1423
+ tags : `tuple[str]`, optional
1424
+ A sequence of tags that must be present on the simulation record.
1425
+ The default tags are ``("prenight", "ideal", "nominal")``. If
1426
+ an empty tuple is supplied, the tag test is omitted.
1427
+ telescope : `str`, optional
1428
+ The telescope simulated. The default is ``simonyi``.
1429
+ max_simulation_age : `int`, optional
1430
+ The maximum age of a simulation in days.
1431
+ The default is 2 days.
1432
+
1433
+ Returns
1434
+ -------
1435
+ vseqs : `pd.DataFrame`
1436
+ A table of metadata for matching simulations.
1437
+ """
1438
+
1439
+ if first_day_obs is None:
1440
+ first_day_obs = datetime.now(timezone(timedelta(hours=-12))).date()
1441
+ if last_day_obs is None:
1442
+ last_day_obs = first_day_obs
1443
+
1444
+ first_day_obs = dayobs_to_date(first_day_obs)
1445
+ last_day_obs = dayobs_to_date(last_day_obs)
1446
+
1447
+ assert isinstance(first_day_obs, date)
1448
+ assert isinstance(last_day_obs, date)
1449
+
1450
+ if len(tags) > 0:
1451
+ tags_json = json.dumps(list(tags))
1452
+ query_template = """
1453
+ SELECT *
1454
+ FROM {}.simulations_extra AS s
1455
+ WHERE %s BETWEEN first_day_obs AND last_day_obs
1456
+ AND %s BETWEEN first_day_obs AND last_day_obs
1457
+ AND telescope = %s
1458
+ AND tags @> %s::JSONB
1459
+ AND creation_time >= NOW() - INTERVAL '%s days'
1460
+ """
1461
+ query_params: Tuple = (first_day_obs, last_day_obs, telescope, tags_json, max_simulation_age)
1462
+ else:
1463
+ query_template = """
1464
+ SELECT *
1465
+ FROM {}.simulations_extra AS s
1466
+ WHERE %s BETWEEN first_day_obs AND last_day_obs
1467
+ AND %s BETWEEN first_day_obs AND last_day_obs
1468
+ AND telescope = %
1469
+ AND creation_time >= NOW() - INTERVAL '%s days'
1470
+ """
1471
+ query_params = (first_day_obs, last_day_obs, telescope, max_simulation_age)
1472
+
1473
+ vseqs = self.pd_read_sql(
1474
+ query_template,
1475
+ [sql.Identifier(self.metadata_db_schema)],
1476
+ query_params,
1477
+ )
1478
+ return vseqs
1479
+
1480
+ def sims_on_night_with_stats(
1481
+ self,
1482
+ day_obs: str | date | int | None = None,
1483
+ tags: tuple[str, ...] = ("prenight", "ideal", "nominal"),
1484
+ telescope: str = "simonyi",
1485
+ max_simulation_age: int = 2,
1486
+ ) -> pd.DataFrame:
1487
+ """Return a table of simulations that cover a given night range.
1488
+
1489
+ Parameters
1490
+ ----------
1491
+ day_obs : `str` | `date` | `int` | `None`, optional
1492
+ The day_obs for which to get simulations.
1493
+ tags : `tuple[str]`, optional
1494
+ A sequence of tags that must be present on the simulation record.
1495
+ The default tags are ``("prenight", "ideal", "nominal")``. If
1496
+ an empty tuple is supplied, the tag test is omitted.
1497
+ telescope : `str`, optional
1498
+ The telescope simulated. The default is ``simonyi``.
1499
+ max_simulation_age : `int`, optional
1500
+ The maximum age of a simulation in days.
1501
+ The default is 2 days.
1502
+
1503
+ Returns
1504
+ -------
1505
+ vseqs : `pd.DataFrame`
1506
+ A table of metadata for matching simulations.
1507
+ """
1508
+
1509
+ if day_obs is None:
1510
+ day_obs = datetime.now(timezone(timedelta(hours=-12))).date()
1511
+
1512
+ day_obs = dayobs_to_date(day_obs)
1513
+ assert isinstance(day_obs, date)
1514
+
1515
+ if len(tags) > 0:
1516
+ tags_json = json.dumps(list(tags))
1517
+ query_template = """
1518
+ WITH aggstats AS (
1519
+ SELECT
1520
+ visitseq_uuid,
1521
+ JSONB_OBJECT_AGG(
1522
+ value_name,
1523
+ TO_JSONB(ns) - 'value_name' - 'visitseq_uuid' - 'day_obs'
1524
+ ) AS stats
1525
+ FROM {}.nightly_stats AS ns
1526
+ GROUP BY ns.visitseq_uuid
1527
+ )
1528
+ SELECT s.*, ns.stats
1529
+ FROM {}.simulations_extra AS s
1530
+ LEFT JOIN aggstats AS ns ON s.visitseq_uuid=ns.visitseq_uuid
1531
+ WHERE %s BETWEEN first_day_obs AND last_day_obs
1532
+ AND telescope = %s
1533
+ AND tags @> %s::JSONB
1534
+ AND creation_time >= NOW() - INTERVAL '%s days'
1535
+ """
1536
+ query_params: Tuple = (day_obs, telescope, tags_json, max_simulation_age)
1537
+ else:
1538
+ query_template = """
1539
+ WITH aggstats AS (
1540
+ SELECT
1541
+ visitseq_uuid,
1542
+ JSONB_OBJECT_AGG(
1543
+ value_name,
1544
+ TO_JSONB(ns) - 'value_name' - 'visitseq_uuid' - 'day_obs'
1545
+ ) AS stats
1546
+ FROM {}.nightly_stats AS ns
1547
+ GROUP BY ns.visitseq_uuid
1548
+ )
1549
+ SELECT s.*, ns.stats
1550
+ FROM {}.simulations_extra AS s
1551
+ LEFT JOIN aggstats AS ns ON s.visitseq_uuid=ns.visitseq_uuid
1552
+ WHERE %s BETWEEN first_day_obs AND last_day_obs
1553
+ AND telescope = %
1554
+ AND creation_time >= NOW() - INTERVAL '%s days'
1555
+ """
1556
+ query_params = (day_obs, telescope, max_simulation_age)
1557
+
1558
+ vseqs = self.pd_read_sql(
1559
+ query_template,
1560
+ [sql.Identifier(self.metadata_db_schema), sql.Identifier(self.metadata_db_schema)],
1561
+ query_params,
1562
+ )
1563
+ return vseqs
1564
+
1565
+ def import_sim_from_yaml(self, metadata_yaml: str, archive_base: str | None = None) -> UUID:
1566
+ """Import a simulation from a YAML metadata description.
1567
+
1568
+ Parameters
1569
+ ----------
1570
+ metadata_yaml : `str`
1571
+ YAML string with simulation metadata.
1572
+ archive_base : `str` or `None`
1573
+ Base location of the archive to which to copy the visit
1574
+ sequence. If ``None``, the visits will not be copied.
1575
+ Defaults to ``None``.
1576
+
1577
+ Returns
1578
+ -------
1579
+ visitseq_uuid : `UUID`
1580
+ The UUID of the newly registered visit sequence.
1581
+ """
1582
+
1583
+ sim_archive_metadata = yaml.safe_load(metadata_yaml)
1584
+
1585
+ if "label" not in sim_archive_metadata:
1586
+ raise ValueError("Metadata yaml must include a label")
1587
+
1588
+ if "files" not in sim_archive_metadata or "observations" not in sim_archive_metadata["files"]:
1589
+ raise ValueError("Metadata yaml must include the observations file")
1590
+
1591
+ label = sim_archive_metadata["label"]
1592
+ record_sim_kwargs = {"label": label}
1593
+
1594
+ # The prenight labels usually end in the creation time.
1595
+ if " ".join(label.split()[-3:-1]) == "run at":
1596
+ maybe_date_str = label.split()[-1]
1597
+ try:
1598
+ record_sim_kwargs["creation_time"] = Time(datetime.fromisoformat(maybe_date_str))
1599
+ except ValueError:
1600
+ pass
1601
+
1602
+ for key in ["scheduler_version", "telescope"]:
1603
+ if key in sim_archive_metadata:
1604
+ record_sim_kwargs[key] = sim_archive_metadata[key]
1605
+
1606
+ for side in ["first", "last"]:
1607
+ try:
1608
+ record_sim_kwargs[f"{side}_day_obs"] = sim_archive_metadata["simulated_dates"][side]
1609
+ except KeyError:
1610
+ pass
1611
+
1612
+ # Get the visits hdf5 file
1613
+ with TemporaryDirectory() as temp_dir:
1614
+ visits_h5_fname = Path(temp_dir) / "visits.h5"
1615
+ obs_rp = ResourcePath(sim_archive_metadata["files"]["observations"]["url"])
1616
+ with obs_rp.as_local() as obs_local_rp:
1617
+ opsimdb_to_hdf5(obs_local_rp.ospath, visits_h5_fname)
1618
+
1619
+ record_sim_kwargs["visits"] = pd.read_hdf(visits_h5_fname, "observations")
1620
+
1621
+ sim_uuid = self.record_simulation_metadata(**record_sim_kwargs)
1622
+
1623
+ if archive_base is not None:
1624
+ # Import here to avoid circular imports
1625
+ from .vseqarchive import add_file
1626
+
1627
+ add_file(self, sim_uuid, visits_h5_fname, "visits", archive_base)
1628
+
1629
+ if "files" in sim_archive_metadata:
1630
+ for file_type in sim_archive_metadata["files"]:
1631
+ file_rp = ResourcePath(sim_archive_metadata["files"][file_type]["url"])
1632
+ file_sha256 = bytes.fromhex(hashlib.sha256(file_rp.read()).hexdigest())
1633
+ self.register_file(sim_uuid, file_type, file_sha256, file_rp)
1634
+
1635
+ if "tags" in sim_archive_metadata:
1636
+ valid_tags = [t for t in sim_archive_metadata["tags"] if isinstance(t, str)]
1637
+ self.tag(sim_uuid, *valid_tags)
1638
+
1639
+ return sim_uuid
1640
+
1641
+ def import_sim_from_prototype_sim_archive(
1642
+ self, archive_base: str, sim_date: str | date, sim_index: str | int, proto_sim_archive_uri: str
1643
+ ) -> UUID:
1644
+ """Import a simulation metadata from the prototype achive.
1645
+
1646
+ Parameters
1647
+ ----------
1648
+ archive_base : `str`
1649
+ Base location where the new archive entry will be written
1650
+ (e.g. an S3 bucket or a local directory). The method
1651
+ will write and hdf5 with visits to this location and
1652
+ register the resulting URL in the database.
1653
+ sim_date : `str` or `datetime.date`
1654
+ The ISO‑formatted date (e.g. ``'2025-03-12'``) or a
1655
+ ``date`` object that identifies (with the sim_index)
1656
+ a simulation in the prototype archive.
1657
+ sim_index : `str` or `int`
1658
+ The index of the simulation which completes the
1659
+ identification of the simulation in the prototype.
1660
+ proto_sim_archive_uri : `str`
1661
+ Base URI of the prototype simulation archive. By default
1662
+ this is ``'s3://rubin:rubin-scheduler-prenight/opsim/'``.
1663
+
1664
+ Returns
1665
+ -------
1666
+ visitseq_uuid : `UUID`
1667
+ The UUID of the visit sequence that was created and
1668
+ registered.
1669
+ """
1670
+
1671
+ metadata_yaml = metadata_yaml_from_prototype(sim_date, sim_index, proto_sim_archive_uri)
1672
+ sim_uuid = self.import_sim_from_yaml(metadata_yaml, archive_base)
1673
+ self.tag(sim_uuid, "from_prototype_sim_archive")
1674
+
1675
+ sim_date_str = sim_date if isinstance(sim_date, str) else sim_date.isoformat()
1676
+ self.comment(sim_uuid, f"Imported from prototype sim_archive {sim_date_str}, {sim_index}")
1677
+ return sim_uuid
1678
+
1679
+ def sim_metadata_yaml(self, visitseq_uuid: UUID) -> str:
1680
+ """Return a YAML representation of a simulation's metadata.
1681
+
1682
+ Parameters
1683
+ ----------
1684
+ visitseq_uuid : `UUID`
1685
+ The unique identifier of the visit sequence for which
1686
+ metadata should be exported.
1687
+
1688
+ Returns
1689
+ -------
1690
+ md_yaml: `str`
1691
+ yaml representation of (some of) the simulation metadata
1692
+ in the metadata database.
1693
+ """
1694
+
1695
+ metadata_seq = self.get_visitseq_metadata(visitseq_uuid, "simulations_extra")
1696
+
1697
+ metadata: dict = {"uuid": str(metadata_seq.visitseq_uuid), "label": str(metadata_seq.visitseq_label)}
1698
+ for key in ["telescope", "tags", "scheduler_version"]:
1699
+ if metadata_seq[key] is not None:
1700
+ metadata[key] = metadata_seq[key]
1701
+
1702
+ if metadata_seq["creation_time"] is not None:
1703
+ metadata["creation_time"] = metadata_seq["creation_time"].isoformat()
1704
+
1705
+ if metadata_seq.first_day_obs is not None or metadata_seq.last_day_obs is not None:
1706
+ metadata["simulation_dates"] = {}
1707
+ if metadata_seq.first_day_obs is not None:
1708
+ metadata["simulation_dates"]["first"] = metadata_seq.first_day_obs
1709
+ if metadata_seq.last_day_obs is not None:
1710
+ metadata["simulation_dates"]["last"] = metadata_seq.last_day_obs
1711
+
1712
+ if metadata_seq.files is not None:
1713
+ metadata["files"] = {}
1714
+ for file_type in metadata_seq.files:
1715
+ metadata["files"][file_type] = {
1716
+ "url": metadata_seq.files[file_type],
1717
+ "name": ResourcePath(metadata_seq.files[file_type]).basename(),
1718
+ }
1719
+
1720
+ metadata_yaml = yaml.dump(metadata)
1721
+ return metadata_yaml