lsst.skymap gf1bbfaee84+baa58b38bb
Loading...
Searching...
No Matches
showVisitSkyMap.py
Go to the documentation of this file.
1#!/usr/bin/env python
2#
3# This file is part of skymap.
4#
5# Developed for the LSST Data Management System.
6# This product includes software developed by the LSST Project
7# (https://www.lsst.org).
8# See the COPYRIGHT file at the top-level directory of this distribution
9# for details of code ownership.
10#
11# This program is free software: you can redistribute it and/or modify
12# it under the terms of the GNU General Public License as published by
13# the Free Software Foundation, either version 3 of the License, or
14# (at your option) any later version.
15#
16# This program is distributed in the hope that it will be useful,
17# but WITHOUT ANY WARRANTY; without even the implied warranty of
18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19# GNU General Public License for more details.
20#
21# You should have received a copy of the GNU General Public License
22# along with this program. If not, see <https://www.gnu.org/licenses/>.
23
24import argparse
25import logging
26import os
27
28import matplotlib
29import matplotlib.patheffects as pathEffects
30import matplotlib.pyplot as plt
31import numpy as np
32from astropy import units
33from astropy.coordinates import SkyCoord
34from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, DetectorType
35from lsst.daf.butler import Butler
36from lsst.geom import Angle, Box2D, Point2D, SpherePoint, degrees
37from lsst.sphgeom import ConvexPolygon
38from matplotlib.legend import Legend
39
40try:
41 from lsst.utils.plotting import publication_plots, set_rubin_plotstyle
42except ImportError:
43 publication_plots = None
44 set_rubin_plotstyle = None
45
46logger = logging.getLogger("lsst.skymap.bin.showVisitSkyMap")
47
48DEFAULT_BAND_COLOR_DICT = {
49 "u": "#48A8D4",
50 "g": "#31DE1F",
51 "r": "#B52626",
52 "i": "#2915A4",
53 "z": "#AD03EA",
54 "y": "#2D0201",
55}
56DEFAULT_BAND_LINESTYLE_DICT = {
57 "u": "solid",
58 "g": "solid",
59 "r": "solid",
60 "i": "solid",
61 "z": "solid",
62 "y": "solid",
63}
64
65BAND_COLOR_DICT = {}
66BAND_LINESTYLE_DICT = {}
67USE_RUBIN_BAND_STYLE = False
68
69
70def bboxToRaDec(bbox, wcs):
71 """Get the corners of a BBox and convert them to lists of RA and Dec."""
72 sphPoints = wcs.pixelToSky(Box2D(bbox).getCorners())
73 ra = [float(sph.getRa().asDegrees()) for sph in sphPoints]
74 dec = [float(sph.getDec().asDegrees()) for sph in sphPoints]
75 return ra, dec
76
77
78def getValueAtPercentile(values, percentile=0.5):
79 """Return a value a fraction of the way between the min and max values in a
80 list.
81
82 Parameters
83 ----------
84 values : `list` [`float`]
85 The list of values under consideration.
86 percentile : `float`, optional
87 The percentile (expressed as a number between 0.0 and 1.0) at which
88 to determine the value in `values`.
89
90 Returns
91 -------
92 result : `float`
93 The value at the given percentile of ``values``.
94 """
95 m = min(values)
96 interval = max(values) - m
97 return m + percentile * interval
98
99
100def get_cmap(n, name="gist_rainbow"):
101 """Returns a function that maps each index in 0, 1, ..., n-1 to a distinct
102 RGB color.
103
104 Uses ``gist_rainbow`` by default: vivid, non-cyclic, so first and last
105 colors are visually distinct.
106 """
107 return matplotlib.colormaps[name].resampled(n)
108
109
110def getBandPlotStyle(band, defaultColor):
111 """Get per-band color/linestyle, falling back to per-visit defaults."""
112 if not USE_RUBIN_BAND_STYLE:
113 return defaultColor, "solid"
114 color = BAND_COLOR_DICT.get(band, defaultColor)
115 linestyle = BAND_LINESTYLE_DICT.get(band, "solid")
116 return color, linestyle
117
118
119def configureBandStyle(useRubinPlotStyle=False):
120 """Configure global band plotting style dictionaries."""
121 global BAND_COLOR_DICT, BAND_LINESTYLE_DICT, USE_RUBIN_BAND_STYLE
122 if useRubinPlotStyle and publication_plots is not None and set_rubin_plotstyle is not None:
123 bandDicts = publication_plots.get_band_dicts()
124 BAND_COLOR_DICT = bandDicts.get("colors", DEFAULT_BAND_COLOR_DICT)
125 BAND_LINESTYLE_DICT = bandDicts.get("line_styles", DEFAULT_BAND_LINESTYLE_DICT)
126 USE_RUBIN_BAND_STYLE = True
128 else:
129 BAND_COLOR_DICT = {}
130 BAND_LINESTYLE_DICT = {}
131 USE_RUBIN_BAND_STYLE = False
132
133
134def queryImageDatasets(butler, whereStr, imageDatasetType=None):
135 """Query image datasets with support for current and legacy names."""
136 datasetTypes = (
137 [imageDatasetType]
138 if imageDatasetType is not None
139 else [
140 "visit_image",
141 "preliminary_visit_image",
142 "calexp",
143 ]
144 )
145 for datasetType in datasetTypes:
146 logger.info("Querying image dataset type: %s", datasetType)
147 dataRefs = list(butler.registry.queryDatasets(datasetType, where=whereStr).expanded())
148 if len(dataRefs) > 0:
149 return datasetType, dataRefs
150 logger.warning("No visit-level data found for any of the tested dataset types; unable to plot visits.")
151 return None, []
152
153
154def getVisitSummaryForVisit(butler, visit, visitSummaryDatasetType=None):
155 """Fetch visit summary for a visit, supporting legacy and newer names."""
156 datasetTypes = (
157 [visitSummaryDatasetType]
158 if visitSummaryDatasetType is not None
159 else [
160 "visit_summary",
161 "preliminary_visit_summary",
162 "visitSummary",
163 ]
164 )
165 for datasetType in datasetTypes:
166 try:
167 return butler.get(datasetType, visit=visit), datasetType
168 except LookupError:
169 pass
170 raise LookupError(f"Visit summary for visit {visit!r} not found in any of {datasetTypes!r}.")
171
172
174 repo,
175 collections,
176 skymapName=None,
177 radec=None,
178 tracts=None,
179 patches=None,
180 visits=None,
181 physicalFilters=None,
182 bands=None,
183 ccds=None,
184 maxVisits=None,
185 visitVetoFile=None,
186 minOverlapFraction=None,
187 showPatch=False,
188 showPatchSelectedTractsOnly=False,
189 showCcds=False,
190 showCcdsAll=False,
191 plotFailsOnly=False,
192 showRawOutlines=False,
193 trimToTracts=False,
194 trimToOverlappingTracts=False,
195 doUnscaledLimitRatio=False,
196 forceScaledLimitRatio=False,
197 maxVisitsForLegend=20,
198 useRubinPlotStyle=False,
199 saveFile=None,
200 dpi=150,
201 ccdKey="detector",
202 imageDatasetType=None,
203 visitSummaryDatasetType=None,
204):
205 if minOverlapFraction is not None and tracts is None:
206 raise RuntimeError("Must specify --tracts if --minOverlapFraction is set")
207 if dpi <= 0:
208 raise RuntimeError("--dpi must be > 0")
209 if maxVisitsForLegend < 0:
210 raise RuntimeError("--maxVisitsForLegend must be >= 0")
211 if maxVisits is not None and maxVisits < 0:
212 raise RuntimeError("--maxVisits must be >= 0")
213 configureBandStyle(useRubinPlotStyle=useRubinPlotStyle)
214 logger.info("Instantiating butler for repo '%s' with collections = %s", repo, collections)
215 butler = Butler.from_config(repo, collections=collections)
216 cameraDataset = butler.find_dataset("camera")
217 if cameraDataset is None:
218 raise RuntimeError("Could not find required dataset type: camera")
219 instrument = str(cameraDataset.dataId["instrument"])
220 detectorSkipList = []
221 # Make a guess at the skymapName if not provided
222 if skymapName is None:
223 if instrument == "HSC":
224 skymapName = "hsc_rings_v1"
225 detectorSkipList = [9] # detector 9 has long been dead for HSC
226 elif instrument == "LSSTCam-imSim":
227 skymapName = "DC2"
228 elif instrument == "LSSTComCamSim":
229 skymapName = "ops_rehersal_prep_2k_v1"
230 elif instrument == "LATISS":
231 skymapName = "latiss_v1"
232 elif instrument == "DECam":
233 skymapName = "decam_rings_v1"
234 elif instrument == "LSSTComCam":
235 skymapName = "lsst_cells_v1"
236 elif instrument == "LSSTCam":
237 skymapName = "lsst_cells_v2"
238 else:
239 raise RuntimeError(
240 f"Unknown skymapName for instrument: {instrument}. Must specify --skymapName on command line."
241 )
242
243 logger.info("Using instrument = '%s' and skymapName = '%s'", instrument, skymapName)
244 camera = butler.get("camera", instrument=instrument)
245 skymap = butler.get("skyMap", instrument=instrument, skymap=skymapName)
246
247 whereStr = ""
248 if tracts is not None:
249 tractStr = makeWhereInStr("tract", tracts, int)
250 whereStr += tractStr
251
252 if patches is not None:
253 patchStr = makeWhereInStr("patch", patches, int)
254 whereStr += " AND " + patchStr if len(whereStr) else patchStr
255
256 if visits is not None:
257 visitStr = makeWhereInStr("exposure", visits, int)
258 if len(whereStr) < 1:
259 whereStr += visitStr
260 else:
261 whereStr += " AND " + visitStr
262
263 if physicalFilters is not None:
264 physicalFilterStr = makeWhereInStr("physical_filter", physicalFilters, str)
265 whereStr += " AND " + physicalFilterStr if len(whereStr) else physicalFilterStr
266
267 if bands is not None:
268 bandStr = makeWhereInStr("band", bands, str)
269 whereStr += " AND " + bandStr if len(whereStr) else bandStr
270
271 if radec is not None:
272 ra, dec = radec
273 radecStr = f"visit.region OVERLAPS POINT({ra:.8f}, {dec:.8f})"
274 whereStr += " AND " + radecStr if len(whereStr) else radecStr
275
276 if len(whereStr) > 1:
277 whereStr = f"instrument='{instrument}' AND skymap='{skymapName}' AND {whereStr}"
278 else:
279 whereStr = f"instrument='{instrument}' AND skymap='{skymapName}'"
280 logger.info("Querying the butler with the following dataId where clause: %s", whereStr)
281
282 imageDatasetTypeUsed, imageDataRefs = queryImageDatasets(
283 butler, whereStr, imageDatasetType=imageDatasetType
284 )
285 logger.info("Using image dataset type: %s", imageDatasetTypeUsed)
286
287 failedDataIds = set()
288 if plotFailsOnly:
289 processedDataIds = set((ref.dataId["visit"], ref.dataId["detector"]) for ref in imageDataRefs)
290 selectedTracts = set(tracts) if tracts is not None else None
291 logger.info("Querying raw datasets to find failed detectors...")
292 rawDataRefs = list(butler.registry.queryDatasets("raw", where=whereStr).expanded())
293 logger.info("Found %d raw datasets", len(rawDataRefs))
294 for ref in rawDataRefs:
295 exposure = ref.dataId["exposure"]
296 detector = ref.dataId["detector"]
297 if (exposure, detector) not in processedDataIds:
298 if selectedTracts is not None:
299 raCorners, decCorners = getDetRaDecCorners(
300 ccdKey,
301 detector,
302 exposure,
303 visitSummary=None,
304 butler=butler,
305 imageDatasetType="raw",
306 doLogWarn=False,
307 )
308 if raCorners is None or decCorners is None:
309 continue
310 finiteCornerPairs = [
311 (ra, dec)
312 for ra, dec in zip(raCorners, decCorners)
313 if np.isfinite(ra) and np.isfinite(dec)
314 ]
315 if len(finiteCornerPairs) == 0:
316 continue
317 rawRas = [ra for ra, _ in finiteCornerPairs]
318 rawDecs = [dec for _, dec in finiteCornerPairs]
319 rawTracts = set(skymap.findTractIdArray(rawRas, rawDecs, degrees=True))
320 if selectedTracts.isdisjoint(rawTracts):
321 continue
322 failedDataIds.add((exposure, detector))
323 logger.info(
324 "Found %d failed (raw but unprocessed) visit-detector pairs",
325 len(failedDataIds),
326 )
327
328 visitSummaryDatasetTypeUsed = visitSummaryDatasetType
329
330 visitVetoList = []
331 if visitVetoFile is not None:
332 with open(visitVetoFile) as f:
333 content = f.readlines()
334 visitVetoList = [int(visit.strip()) for visit in content]
335
336 if visits is None:
337 visits = []
338 if plotFailsOnly:
339 for failVisit, _ in failedDataIds:
340 if failVisit not in visits and failVisit not in visitVetoList:
341 visits.append(failVisit)
342 else:
343 for dataRef in imageDataRefs:
344 visit = dataRef.dataId.visit.id
345 if visit not in visits and visit not in visitVetoList:
346 visits.append(visit)
347 visits.sort()
348 if maxVisits is not None and len(visits) > maxVisits:
349 logger.info("Trimming visits from N=%d to N=%d due to --maxVisits", len(visits), maxVisits)
350 visits = visits[:maxVisits]
351 logger.info("List of visits (N=%d) satisfying selection filters: %s", len(visits), visits)
352 else:
353 if len(visitVetoList) > 1:
354 visitListTemp = visits.copy()
355 for visit in visitListTemp:
356 if visit in visitVetoList:
357 visits.remove(visit)
358 logger.info("List of visits (N=%d) excluding veto list: %s}", len(visits), visits)
359 logger.info("List of visits (N=%d): %s", len(visits), visits)
360
361 if len(visits) > 0:
362 try:
363 _, visitSummaryDatasetTypeUsed = getVisitSummaryForVisit(
364 butler, visits[0], visitSummaryDatasetType=visitSummaryDatasetType
365 )
366 logger.info("Using visit summary dataset type: %s", visitSummaryDatasetTypeUsed)
367 except LookupError:
368 if visitSummaryDatasetType is None:
369 logger.info(
370 "No visit summary dataset type found for auto-detection; "
371 "will fall back to detector-level WCS/bbox lookups."
372 )
373 else:
374 logger.info(
375 "Configured visit summary dataset type '%s' was not found for sampled visit; "
376 "will fall back to detector-level WCS/bbox lookups.",
377 visitSummaryDatasetType,
378 )
379
380 ccdIdList = []
381 for ccd in camera:
382 ccdId = ccd.getId()
383 if (
384 (ccds is None or ccdId in ccds)
385 and ccd.getType() == DetectorType.SCIENCE
386 and ccdId not in detectorSkipList
387 ):
388 ccdIdList.append(ccdId)
389 ccdIdList.sort()
390 nDetTot = len(ccdIdList)
391 missingVisitSummaryRows = {}
392 nonFiniteDetectorCorners = {}
393
394 visitIncludeList = []
395 # Determine the fraction of detectors that overlap any tract under
396 # consideration. If this fraction does not exceed minOverlapFraction,
397 # skip this visit.
398 if minOverlapFraction is not None:
399 for i_v, visit in enumerate(visits):
400 ccdOverlapList = []
401 try:
402 visitSummary, _ = getVisitSummaryForVisit(
403 butler, visit, visitSummaryDatasetType=visitSummaryDatasetTypeUsed
404 )
405 except LookupError as e:
406 logger.warning("%s Will try to get wcs from %s.", e, imageDatasetTypeUsed)
407 visitSummary = None
408 if tracts is not None:
409 for tract in tracts:
410 tractInfo = skymap[tract]
411 sphCorners = tractInfo.wcs.pixelToSky(Box2D(tractInfo.bbox).getCorners())
412 tractConvexHull = ConvexPolygon.convexHull([coord.getVector() for coord in sphCorners])
413 for ccdId in ccdIdList:
414 if ccdId not in ccdOverlapList:
415 raCorners, decCorners = getDetRaDecCorners(
416 ccdKey,
417 ccdId,
418 visit,
419 visitSummary=visitSummary,
420 butler=butler,
421 imageDatasetType=imageDatasetTypeUsed,
422 doLogWarn=False,
423 )
424 if raCorners is not None and decCorners is not None:
425 finiteCornerPairs = [
426 (ra, dec)
427 for ra, dec in zip(raCorners, decCorners)
428 if np.isfinite(ra) and np.isfinite(dec)
429 ]
430 if len(finiteCornerPairs) < 3:
431 logger.debug(
432 "visit %d det %d (tract-overlap path): only %d finite "
433 "corner(s); raw ra=%s dec=%s",
434 visit,
435 ccdId,
436 len(finiteCornerPairs),
437 raCorners,
438 decCorners,
439 )
440 continue
441 detSphCorners = []
442 for ra, dec in finiteCornerPairs:
443 pt = SpherePoint(Angle(ra, degrees), Angle(dec, degrees))
444 detSphCorners.append(pt)
445 try:
446 detConvexHull = ConvexPolygon.convexHull(
447 [coord.getVector() for coord in detSphCorners]
448 )
449 except ValueError as e:
450 logger.debug(
451 "visit %d det %d (tract-overlap path): hull ValueError (%s); "
452 "corners ra=%s dec=%s",
453 visit,
454 ccdId,
455 e,
456 raCorners,
457 decCorners,
458 )
459 continue
460 if tractConvexHull.contains(detConvexHull):
461 ccdOverlapList.append(ccdId)
462
463 if len(ccdOverlapList) / nDetTot >= minOverlapFraction:
464 break
465 if len(ccdOverlapList) / nDetTot < minOverlapFraction:
466 logger.info(
467 "Fraction of detectors overlapping any tract for visit %d (%.2f) < "
468 "minimum required (%.2f). Skipping visit...",
469 visit,
470 len(ccdOverlapList) / nDetTot,
471 minOverlapFraction,
472 )
473 else:
474 if visit not in visitIncludeList:
475 visitIncludeList.append(visit)
476 else:
477 visitIncludeList = visits
478
479 # Draw the CCDs.
480 ras, decs = [], []
481 ccdBBoxesPlotted = [] # Bounding boxes for CCDs that were actually drawn
482 ccdLabelsToPlot = [] # Store CCD label info to plot after limits finalized
483 cmap = get_cmap(len(visitIncludeList))
484 alphaEdge = 0.7
485 finalVisitList = []
486 finalVisitColorIndices = []
487 includedBands = []
488 includedPhysicalFilters = []
489 for i_v, visit in enumerate(visitIncludeList):
490 print("Working on visit %d [%d of %d]" % (visit, i_v + 1, len(visitIncludeList)), end="\r")
491 inLegend = False
492 defaultColor = cmap(i_v)
493 try:
494 visitSummary, _ = getVisitSummaryForVisit(
495 butler, visit, visitSummaryDatasetType=visitSummaryDatasetTypeUsed
496 )
497 except Exception as e:
498 logger.warning("%s Will try to get wcs from %s.", e, imageDatasetTypeUsed)
499 visitSummary = None
500
501 band, physicalFilter = getBand(visitSummary=visitSummary, butler=butler, visit=visit)
502 if band not in includedBands:
503 includedBands.append(band)
504 if physicalFilter not in includedPhysicalFilters:
505 includedPhysicalFilters.append(physicalFilter)
506
507 color, linestyle = getBandPlotStyle(band, defaultColor)
508 fillKwargs = {
509 "fill": False,
510 "alpha": alphaEdge,
511 "facecolor": None,
512 "edgecolor": color,
513 "lw": 0.6,
514 "ls": linestyle,
515 }
516
517 for ccdId in ccdIdList:
518 if plotFailsOnly and (visit, ccdId) not in failedDataIds:
519 continue
520 if showRawOutlines and not plotFailsOnly:
521 rawRaCorners, rawDecCorners = getDetRaDecCorners(
522 ccdKey,
523 ccdId,
524 visit,
525 visitSummary=None,
526 butler=butler,
527 imageDatasetType="raw",
528 doLogWarn=False,
529 )
530 if rawRaCorners is not None and rawDecCorners is not None:
531 rawCornerPairs = list(zip(rawRaCorners, rawDecCorners))
532 finiteRawCornerPairs = [
533 (ra, dec) for ra, dec in rawCornerPairs if np.isfinite(ra) and np.isfinite(dec)
534 ]
535 if len(finiteRawCornerPairs) == len(rawCornerPairs):
536 plt.fill(
537 rawRaCorners,
538 rawDecCorners,
539 fill=False,
540 alpha=alphaEdge,
541 edgecolor=color,
542 ls=":",
543 lw=0.8,
544 )
545 raCorners, decCorners = getDetRaDecCorners(
546 ccdKey,
547 ccdId,
548 visit,
549 visitSummary=None if plotFailsOnly else visitSummary,
550 butler=butler,
551 imageDatasetType="raw" if plotFailsOnly else imageDatasetTypeUsed,
552 missingVisitSummaryRows=missingVisitSummaryRows,
553 )
554 if raCorners is not None and decCorners is not None:
555 cornerPairs = list(zip(raCorners, decCorners))
556 finiteCornerPairs = [
557 (ra, dec) for ra, dec in cornerPairs if np.isfinite(ra) and np.isfinite(dec)
558 ]
559 if len(finiteCornerPairs) < len(cornerPairs):
560 nonFiniteDetectorCorners.setdefault(visit, set()).add(ccdId)
561 # Skip plotting malformed polygons for this detector.
562 continue
563
564 ras += raCorners
565 decs += decCorners
566 if not inLegend and len(visitIncludeList) <= maxVisitsForLegend:
567 plt.fill(raCorners, decCorners, label=str(visit), **fillKwargs)
568 inLegend = True
569 else:
570 plt.fill(raCorners, decCorners, **fillKwargs)
571 plt.fill(raCorners, decCorners, fill=True, alpha=alphaEdge / 4, color=color, edgecolor=color)
572 if visit not in finalVisitList:
573 finalVisitList.append(visit)
574 finalVisitColorIndices.append(i_v)
575 # Collect bboxes and CCD label info for later
576 if showCcds or showCcdsAll:
577 ccdCenterRa = getValueAtPercentile(raCorners)
578 ccdCenterDec = getValueAtPercentile(decCorners)
579 overlapFrac = 0.2
580 deltaRa = max(raCorners) - min(raCorners)
581 deltaDec = max(decCorners) - min(decCorners)
582 minPoint = Point2D(
583 min(raCorners) + overlapFrac * deltaRa, min(decCorners) + overlapFrac * deltaDec
584 )
585 maxPoint = Point2D(
586 max(raCorners) - overlapFrac * deltaRa, max(decCorners) - overlapFrac * deltaDec
587 )
588 bboxDouble = Box2D(minPoint, maxPoint)
589 ccdLabelsToPlot.append(
590 {
591 "ra": ccdCenterRa,
592 "dec": ccdCenterDec,
593 "ccdId": ccdId,
594 "bbox": bboxDouble,
595 }
596 )
597
598 if visit in missingVisitSummaryRows:
599 missingDetectors = sorted(missingVisitSummaryRows[visit])
600 logger.warning(
601 "visit summary table for visit %d missing detectors: %s",
602 visit,
603 missingDetectors,
604 )
605
606 if visit in nonFiniteDetectorCorners:
607 badDetectors = sorted(nonFiniteDetectorCorners[visit])
608 logger.warning(
609 "Non-finite detector corners for visit %d (N=%d): %s",
610 visit,
611 len(badDetectors),
612 badDetectors,
613 )
614
615 logger.info(
616 "Final list of visits (N=%d) satisfying where and minOverlapFraction clauses: %s",
617 len(finalVisitList),
618 finalVisitList,
619 )
620
621 raToDecLimitRatio = None
622 midRa = None
623 midDec = None
624 midSkyCoord = None
625 if len(ras) > 0:
626 finiteCoordPairs = [(ra, dec) for ra, dec in zip(ras, decs) if np.isfinite(ra) and np.isfinite(dec)]
627 droppedCoordCount = len(ras) - len(finiteCoordPairs)
628 if droppedCoordCount > 0:
629 logger.warning(
630 "Dropping %d non-finite detector corner coordinates before tract lookup",
631 droppedCoordCount,
632 )
633 if len(finiteCoordPairs) == 0:
634 if tracts is not None:
635 logger.info(
636 "No finite detector corners found, but --tracts list was provided, so will go ahead and "
637 "plot the empty tracts."
638 )
639 tractList = tracts
640 trimToTracts = True
641 else:
642 raise RuntimeError("No finite detector corners available for tract lookup")
643 else:
644 ras, decs = zip(*finiteCoordPairs)
645 ras = list(ras)
646 decs = list(decs)
647 tractList = list(set(skymap.findTractIdArray(ras, decs, degrees=True)))
648 minVisitRa, maxVisitRa = min(ras), max(ras)
649 minVisitDec, maxVisitDec = min(decs), max(decs)
650 raVisitDiff = maxVisitRa - minVisitRa
651 decVisitDiff = maxVisitDec - minVisitDec
652 midVisitRa = minVisitRa + 0.5 * raVisitDiff
653 midVisitDec = minVisitDec + 0.5 * decVisitDiff
654 midRa = np.atleast_1d((midVisitRa * units.deg).to(units.radian).value).astype(np.float64)
655 midDec = np.atleast_1d((midVisitDec * units.deg).to(units.radian).value).astype(np.float64)
656 midSkyCoord = SkyCoord(midVisitRa * units.deg, midVisitDec * units.deg)
657 else:
658 if tracts is not None:
659 logger.info(
660 "No detectors were found, but --tracts list was provided, so will go ahead and "
661 "plot the empty tracts."
662 )
663 tractList = tracts
664 trimToTracts = True
665 else:
666 if radec is not None:
667 logger.info(
668 "No detectors found; centering on provided RA/Dec (%.5f, %.5f).",
669 radec[0],
670 radec[1],
671 )
672 radecSphPoint = SpherePoint(Angle(radec[0], degrees), Angle(radec[1], degrees))
673 tractId = skymap.findTract(radecSphPoint).getId()
674 tractList = [tractId]
675 trimToTracts = True
676 else:
677 raise RuntimeError(
678 "No data to plot (if you want to plot empty tracts, include them as "
679 "a whitespace-separated list to the --tracts option)."
680 )
681 tractList, invalidTracts = sanitizeTractList(skymap, tractList)
682 if len(invalidTracts) > 0:
683 logger.warning("Ignoring invalid tract ids: %s", invalidTracts)
684 if len(tractList) == 0:
685 raise RuntimeError("No valid tract ids found for plotting")
686 logger.info("List of tracts overlapping data: %s", tractList)
687
688 # Determine which tracts to use for plot axis limits.
689 # trimToOverlappingTracts always uses all tracts overlapping the visits;
690 # trimToTracts uses user tracts when available, else falls back
691 # to overlapping tracts; otherwise plot limits come from visit footprints.
692 if trimToOverlappingTracts:
693 tractLimitsForPlotting = tractList
694 elif trimToTracts:
695 tractLimitsForPlotting = tracts if tracts is not None else tractList
696 else:
697 tractLimitsForPlotting = None # use visit footprints for plot limits
698
699 # Compute the tract limits dict only if needed for plot limits.
700 if tractLimitsForPlotting is not None:
701 tractLimitsDict = getTractLimitsDict(skymap, tractLimitsForPlotting)
702 else:
703 tractLimitsDict = None
704
705 if forceScaledLimitRatio:
706 doUnscaledLimitRatio = False
707 else:
708 # Roughly compute radius in degrees of a single detector. If RA/Dec
709 # coverage is more than 30 times the detector radius, and the RA/Dec
710 # limit ratio is greater than raDecScaleThresh, don't try to scale to
711 # detector coords.
712 radiusMm = camera.computeMaxFocalPlaneRadius()
713 fpRadiusPt = Point2D(radiusMm, radiusMm)
714 focalPlaneToFieldAngle = camera.getTransformMap().getTransform(FOCAL_PLANE, FIELD_ANGLE)
715 fpRadiusDeg = np.rad2deg(focalPlaneToFieldAngle.applyForward(fpRadiusPt))[0]
716 detectorRadiusDeg = fpRadiusDeg / np.sqrt(len(camera))
717
718 if tractLimitsDict is not None:
719 xLimMin, xLimMax, yLimMin, yLimMax = getMinMaxLimits(tractLimitsDict)
720 xDelta0 = xLimMax - xLimMin
721 yDelta0 = yLimMax - yLimMin
722 else:
723 xDelta0 = raVisitDiff
724 yDelta0 = decVisitDiff
725 yLimMin = minVisitDec
726 yLimMax = maxVisitDec
727 raDecScaleThresh = 1.5 # This is a best guess with current testing.
728 if (
729 (xDelta0 / yDelta0 > raDecScaleThresh or yDelta0 / xDelta0 > raDecScaleThresh)
730 and max(xDelta0, yDelta0) > 70 * detectorRadiusDeg
731 and yLimMin < 75.0
732 and yLimMax > -75.0
733 ):
734 logger.info(
735 "Sky coverage is large (and not too close to a pole), so not scaling to detector coords."
736 )
737 doUnscaledLimitRatio = True
738
739 if not doUnscaledLimitRatio and midSkyCoord is not None:
740 # Find a detector that contains the mid point in RA/Dec (or the closest
741 # one) to set the plot aspect ratio.
742 minDistToMidCoord = 1e12
743 minSepVisit = None
744 minSepCcdId = None
745 for i_v, visit in enumerate(visits):
746 try:
747 visitSummary, _ = getVisitSummaryForVisit(
748 butler, visit, visitSummaryDatasetType=visitSummaryDatasetTypeUsed
749 )
750 except Exception as e:
751 logger.warning("%s Will try to get wcs from %s.", e, imageDatasetTypeUsed)
752 visitSummary = None
753 for ccdId in ccdIdList:
754 raCorners, decCorners = getDetRaDecCorners(
755 ccdKey,
756 ccdId,
757 visit,
758 visitSummary=visitSummary,
759 butler=butler,
760 imageDatasetType=imageDatasetTypeUsed,
761 doLogWarn=False,
762 )
763 if raCorners is not None and decCorners is not None:
764 finiteCornerPairs = [
765 (ra, dec)
766 for ra, dec in zip(raCorners, decCorners)
767 if np.isfinite(ra) and np.isfinite(dec)
768 ]
769 if len(finiteCornerPairs) < 3:
770 logger.debug(
771 "visit %d det %d (midpoint path): only %d finite corner(s); raw ra=%s dec=%s",
772 visit,
773 ccdId,
774 len(finiteCornerPairs),
775 raCorners,
776 decCorners,
777 )
778 continue
779 detSphCorners = []
780 for ra, dec in finiteCornerPairs:
781 pt = SpherePoint(Angle(ra, degrees), Angle(dec, degrees))
782 detSphCorners.append(pt)
783 ptSkyCoord = SkyCoord(ra * units.deg, dec * units.deg)
784 separation = (midSkyCoord.separation(ptSkyCoord)).degree
785 if separation < minDistToMidCoord:
786 minSepVisit = visit
787 minSepCcdId = ccdId
788 minDistToMidCoord = separation
789 try:
790 detConvexHull = ConvexPolygon([coord.getVector() for coord in detSphCorners])
791 except ValueError as e:
792 logger.debug(
793 "visit %d det %d (midpoint path): hull ValueError (%s); corners ra=%s dec=%s",
794 visit,
795 ccdId,
796 e,
797 raCorners,
798 decCorners,
799 )
800 continue
801 if detConvexHull.contains(midRa, midDec) and raToDecLimitRatio is None:
802 logger.info(
803 "visit/det overlapping plot coord mid point in RA/Dec: %d %d", visit, ccdId
804 )
805 raToDecLimitRatio = (max(raCorners) - min(raCorners)) / (
806 max(decCorners) - min(decCorners)
807 )
808 det = camera[ccdId]
809 width = det.getBBox().getWidth()
810 height = det.getBBox().getHeight()
811 if raToDecLimitRatio > 1.0:
812 raToDecLimitRatio /= max(height / width, width / height)
813 else:
814 if raToDecLimitRatio < 1.0:
815 raToDecLimitRatio *= max(height / width, width / height)
816 break
817 if raToDecLimitRatio is not None:
818 break
819
820 if raToDecLimitRatio is None and minSepVisit is not None:
821 canComputeNearestDetRatio = True
822 try:
823 visitSummary, _ = getVisitSummaryForVisit(
824 butler, minSepVisit, visitSummaryDatasetType=visitSummaryDatasetTypeUsed
825 )
826 except Exception as e:
827 logger.warning("%s Will try to get wcs from %s.", e, imageDatasetTypeUsed)
828 visitSummary = None
829 raCorners, decCorners = getDetRaDecCorners(
830 ccdKey,
831 minSepCcdId,
832 minSepVisit,
833 visitSummary=visitSummary,
834 butler=butler,
835 imageDatasetType=imageDatasetTypeUsed,
836 doLogWarn=False,
837 )
838 if raCorners is None or decCorners is None:
839 logger.warning(
840 "Could not determine detector corners for visit/det nearest plot midpoint: %d %d",
841 minSepVisit,
842 minSepCcdId,
843 )
844 canComputeNearestDetRatio = False
845 else:
846 finiteCornerPairs = [
847 (ra, dec)
848 for ra, dec in zip(raCorners, decCorners)
849 if np.isfinite(ra) and np.isfinite(dec)
850 ]
851 if len(finiteCornerPairs) < 3:
852 logger.warning(
853 "Insufficient finite detector corners for visit/det nearest plot midpoint: %d %d",
854 minSepVisit,
855 minSepCcdId,
856 )
857 canComputeNearestDetRatio = False
858 else:
859 raCorners = [ra for ra, _ in finiteCornerPairs]
860 decCorners = [dec for _, dec in finiteCornerPairs]
861 if canComputeNearestDetRatio:
862 logger.info(
863 "visit/det closest to plot coord mid point in RA/Dec (none actually overlap it): %d %d",
864 minSepVisit,
865 minSepCcdId,
866 )
867 raToDecLimitRatio = (max(raCorners) - min(raCorners)) / (max(decCorners) - min(decCorners))
868 det = camera[minSepCcdId]
869 width = det.getBBox().getWidth()
870 height = det.getBBox().getHeight()
871 if raToDecLimitRatio > 1.0:
872 raToDecLimitRatio /= max(height / width, width / height)
873 else:
874 if raToDecLimitRatio < 1.0:
875 raToDecLimitRatio *= max(height / width, width / height)
876
877 elif not doUnscaledLimitRatio:
878 logger.info(
879 "Skipping midpoint-based detector aspect-ratio scaling: "
880 "no detector-derived midpoint is available."
881 )
882
883 if plotFailsOnly and not doUnscaledLimitRatio and raToDecLimitRatio is None:
884 # In fail-only mode we can lack midpoint detector context; default to
885 # 1:1 sky-coordinate limits for predictable geometry.
886 raToDecLimitRatio = 1.0
887
888 if tractLimitsDict is not None:
889 xlim, ylim = derivePlotLimits(tractLimitsDict, raToDecLimitRatio=raToDecLimitRatio, buffFrac=0.04)
890 else:
891 visitLimitsDict = {"allVisits": {"ras": [minVisitRa, maxVisitRa], "decs": [minVisitDec, maxVisitDec]}}
892 xlim, ylim = derivePlotLimits(visitLimitsDict, raToDecLimitRatio=raToDecLimitRatio, buffFrac=0.04)
893
894 if doUnscaledLimitRatio:
895 boxAspectRatio = abs((ylim[1] - ylim[0]) / (xlim[1] - xlim[0]))
896 else:
897 boxAspectRatio = 1.0
898
899 # Plot deferred CCD labels after plot limits are finalized.
900 ccdLabelEdgeBuffer = 0.03 # fraction of plot width/height to use as inset
901 ccdBufRa = ccdLabelEdgeBuffer * abs(xlim[1] - xlim[0])
902 ccdBufDec = ccdLabelEdgeBuffer * abs(ylim[1] - ylim[0])
903 for ccdLabel in ccdLabelsToPlot:
904 # Keep center inside buffered bounds (RA xlim may be inverted).
905 if (
906 min(xlim) + ccdBufRa < ccdLabel["ra"] < max(xlim) - ccdBufRa
907 and min(ylim) + ccdBufDec < ccdLabel["dec"] < max(ylim) - ccdBufDec
908 ):
909 if showCcdsAll:
910 plt.text(
911 ccdLabel["ra"],
912 ccdLabel["dec"],
913 str(ccdLabel["ccdId"]),
914 fontsize=6,
915 ha="center",
916 va="center",
917 color="darkblue",
918 )
919 else:
920 overlaps = [ccdLabel["bbox"].overlaps(otherBbox) for otherBbox in ccdBBoxesPlotted]
921 if not any(overlaps):
922 plt.text(
923 ccdLabel["ra"],
924 ccdLabel["dec"],
925 str(ccdLabel["ccdId"]),
926 fontsize=6,
927 ha="center",
928 va="center",
929 color="darkblue",
930 )
931 ccdBBoxesPlotted.append(ccdLabel["bbox"])
932
933 # Draw the skymap.
934 alpha0 = 1.0
935 tractHandleList = []
936 tractStrList = []
937 if tracts is not None:
938 tractOutlineList = list(set(tracts + tractList))
939 else:
940 tractOutlineList = tractList
941 tractOutlineList.sort()
942 selectedPatchTracts = set(tracts) if tracts is not None else None
943 if showPatchSelectedTractsOnly and selectedPatchTracts is None:
944 logger.warning(
945 "--showPatchSelectedTractsOnly was set without --tracts; showing patches for all plotted tracts."
946 )
947 logger.info("List of tract outlines being plotted: %s", tractOutlineList)
948 for i_t, tract in enumerate(tractOutlineList):
949 alpha = max(0.1, alpha0 - i_t * 1.0 / len(tractOutlineList))
950 tractInfo = skymap[tract]
951 tCenter = tractInfo.ctr_coord
952 tCenterRa = tCenter.getRa().asDegrees()
953 tCenterDec = tCenter.getDec().asDegrees()
954 fracDeltaX = 0.02 * abs((xlim[1] - xlim[0]))
955 fracDeltaY = 0.02 * abs((ylim[1] - ylim[0]))
956 if (
957 xlim[1] + fracDeltaX < tCenterRa < xlim[0] - fracDeltaX
958 and ylim[0] + fracDeltaY < tCenterDec < ylim[1] - fracDeltaY
959 ):
960 if len(tractOutlineList) > 1 or not showPatch:
961 if not showPatch:
962 plt.text(tCenterRa, tCenterDec, tract, fontsize=7, alpha=alpha, ha="center", va="center")
963 else:
964 plt.text(
965 tCenterRa,
966 tCenterDec,
967 tract,
968 fontsize=7,
969 alpha=1,
970 color="white",
971 path_effects=[pathEffects.withStroke(linewidth=3, foreground="black")],
972 fontweight=500,
973 ha="center",
974 va="center",
975 zorder=5,
976 )
977 ra, dec = bboxToRaDec(tractInfo.bbox, tractInfo.getWcs())
978 plt.fill(ra, dec, fill=False, edgecolor="k", lw=1, linestyle="dashed", alpha=alpha)
979 tractArtist = matplotlib.patches.Patch(fill=False, edgecolor="k", linestyle="dashed", alpha=alpha)
980 tractHandleList.append(tractArtist)
981 tractStrList.append(str(tract))
982 if showPatch and (
983 not showPatchSelectedTractsOnly or selectedPatchTracts is None or tract in selectedPatchTracts
984 ):
985 patchColor = "k"
986 for patch in tractInfo:
987 ra, dec = bboxToRaDec(patch.getInnerBBox(), tractInfo.getWcs())
988 plt.fill(ra, dec, fill=False, edgecolor=patchColor, lw=0.5, linestyle="dotted", alpha=alpha)
989 if (
990 xlim[1] + fracDeltaX < getValueAtPercentile(ra) < xlim[0] - fracDeltaX
991 and ylim[0] + fracDeltaY < getValueAtPercentile(dec) < ylim[1] - fracDeltaY
992 ):
993 plt.text(
996 str(patch.sequential_index),
997 fontsize=5,
998 color=patchColor,
999 ha="center",
1000 va="center",
1001 alpha=alpha,
1002 )
1003
1004 # Plot the RA/Dec marker if provided.
1005 if radec is not None:
1006 plt.plot(
1007 radec[0],
1008 radec[1],
1009 marker="*",
1010 markersize=12,
1011 color="red",
1012 markeredgecolor="black",
1013 markeredgewidth=0.8,
1014 zorder=10,
1015 )
1016
1017 # Add labels and save.
1018 ax = plt.gca()
1019 ax.set_xlim(xlim)
1020 ax.set_ylim(ylim)
1021 ax.set_box_aspect(boxAspectRatio)
1022 if abs(xlim[1] > 99.99):
1023 ax.tick_params("x", labelrotation=45, pad=0, labelsize=8)
1024 else:
1025 ax.tick_params("x", labelrotation=0, pad=0, labelsize=8)
1026 ax.tick_params("y", labelsize=8)
1027 ax.set_xlabel("RA (deg)", fontsize=9)
1028 ax.set_ylabel("Dec (deg)", fontsize=9)
1029
1030 if len(visitIncludeList) > maxVisitsForLegend:
1031 nVisits = len(finalVisitList)
1032 nz = matplotlib.colors.Normalize(vmin=0, vmax=len(visitIncludeList) - 1)
1033 cax, _ = matplotlib.colorbar.make_axes(plt.gca(), pad=0.03)
1034 cax.tick_params(labelsize=7)
1035 cb = matplotlib.colorbar.ColorbarBase(cax, cmap=cmap, norm=nz, alpha=alphaEdge)
1036 nTicks = min(8, nVisits)
1037 tickPositions = [finalVisitColorIndices[int(round(x))] for x in np.linspace(0, nVisits - 1, nTicks)]
1038 cb.set_ticks(tickPositions)
1039 cb.set_ticklabels([str(finalVisitList[int(round(x))]) for x in np.linspace(0, nVisits - 1, nTicks)])
1040 cb.ax.yaxis.get_offset_text().set_fontsize(7)
1041 cb.set_label("visit", rotation=-90, labelpad=13, fontsize=9)
1042 tractLegend = Legend(
1043 ax,
1044 tractHandleList,
1045 tractStrList,
1046 loc="upper right",
1047 fancybox=True,
1048 shadow=True,
1049 fontsize=5,
1050 title_fontsize=6,
1051 title="tracts",
1052 )
1053 ax.add_artist(tractLegend)
1054 else:
1055 if len(visitIncludeList) > 0:
1056 handles, labels = ax.get_legend_handles_labels()
1057 if len(handles) > 0:
1058 xBboxAnchor = min(1.25, max(1.03, boxAspectRatio * 1.15))
1059 ax.legend(
1060 loc="center left",
1061 bbox_to_anchor=(xBboxAnchor, 0.5),
1062 fancybox=True,
1063 shadow=True,
1064 fontsize=6,
1065 title_fontsize=6,
1066 title="visits",
1067 )
1068 # Create the second legend and add the artist manually.
1069 tractLegend = Legend(
1070 ax,
1071 tractHandleList,
1072 tractStrList,
1073 loc="center left",
1074 bbox_to_anchor=(1.0, 0.5),
1075 fancybox=True,
1076 shadow=True,
1077 fontsize=6,
1078 title_fontsize=6,
1079 title="tracts",
1080 )
1081 ax.add_artist(tractLegend)
1082
1083 titleStr = repo + "\n" + collections[0]
1084 if len(collections) > 1:
1085 for collection in collections[1:]:
1086 titleStr += "\n" + collection
1087 if plotFailsOnly:
1088 titleStr += "\nFailed calibration: N={}".format(len(failedDataIds))
1089 else:
1090 titleStr += "\nnVisit: {}".format(str(len(finalVisitList)))
1091 if minOverlapFraction is not None:
1092 titleStr += " (minOverlapFraction = {:.2f})".format(minOverlapFraction)
1093 if len(includedBands) > 0:
1094 titleStr += ", bands: {}".format(str(includedBands).translate({ord(i): None for i in "[]'"}))
1095 if len(includedPhysicalFilters) > 0:
1096 if len(includedPhysicalFilters[0]) > 9:
1097 titleStr += "\n"
1098 else:
1099 titleStr += ","
1100 titleStr += " physical filters: {}".format(
1101 str(includedPhysicalFilters).translate({ord(i): None for i in "[]'"})
1102 )
1103 ax.set_title("{}".format(titleStr), fontsize=8)
1104
1105 fig = plt.gcf()
1106 if boxAspectRatio > 1.0:
1107 minInches = max(4.0, 0.3 * abs(xlim[1] - xlim[0]))
1108 xInches = minInches
1109 yInches = min(120.0, boxAspectRatio * minInches)
1110 fig.set_size_inches(xInches, yInches)
1111 if boxAspectRatio < 1.0:
1112 minInches = max(4.0, 0.3 * abs(ylim[1] - ylim[0]))
1113 xInches = min(120.0, minInches / boxAspectRatio)
1114 yInches = minInches
1115 fig.set_size_inches(xInches, yInches)
1116 if saveFile is not None:
1117 fileRoot, fileExt = os.path.splitext(saveFile)
1118 if plotFailsOnly and "fail" not in saveFile:
1119 saveFile = f"{fileRoot}_failed{fileExt}"
1120 if fileExt == "":
1121 defaultSaveFormat = matplotlib.rcParams.get("savefig.format", "png")
1122 saveFile = f"{saveFile}.{defaultSaveFormat}"
1123 saveFile = os.path.normpath(saveFile)
1124 logger.info("Saving file in: %s", saveFile)
1125 fig.savefig(saveFile, bbox_inches="tight", dpi=dpi)
1126 else:
1127 fig.show()
1128
1129
1130def makeWhereInStr(parameterName, parameterList, parameterType):
1131 """Create the string to be used in the where clause for registry lookup."""
1132 typeStr = "'" if parameterType is str else ""
1133 whereInStr = parameterName + " IN (" + typeStr + str(parameterList[0]) + typeStr
1134 if len(parameterList) > 1:
1135 for param in parameterList[1:]:
1136 whereInStr += ", " + typeStr + str(param) + typeStr
1137 whereInStr += ")"
1138
1139 return whereInStr
1140
1141
1142def sanitizeTractList(skymap, tractList):
1143 """Split tract ids into valid and invalid entries for the given skymap."""
1144 validTracts = []
1145 invalidTracts = []
1146 for tract in tractList:
1147 tractInt = int(tract)
1148 try:
1149 _ = skymap[tractInt]
1150 except IndexError:
1151 invalidTracts.append(tractInt)
1152 continue
1153 if tractInt not in validTracts:
1154 validTracts.append(tractInt)
1155 validTracts.sort()
1156 invalidTracts.sort()
1157 return validTracts, invalidTracts
1158
1159
1160def getTractLimitsDict(skymap, tractList):
1161 """Return a dict containing tract limits needed for outline plotting.
1162
1163 Parameters
1164 ----------
1165 skymap : `lsst.skymap.BaseSkyMap`
1166 The sky map used for this dataset. Used to obtain tract
1167 parameters.
1168 tractList : `list` [`int`]
1169 The list of tract ids (as integers) for which to determine the
1170 limits.
1171
1172 Returns
1173 -------
1174 tractLimitsDict : `dict` [`dict`]
1175 A dictionary keyed on tract id. Each entry includes a `dict`
1176 including the tract RA corners, Dec corners, and the tract center,
1177 all in units of degrees. These are used for plotting the tract
1178 outlines.
1179 """
1180 tractLimitsDict = {}
1181 for tract in tractList:
1182 tractInfo = skymap[tract]
1183 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox()
1184 tractCenter = tractBbox.getCenter()
1185 tractRa0 = (tractCenter[0] - tractBbox.getWidth() / 2).asDegrees()
1186 tractRa1 = (tractCenter[0] + tractBbox.getWidth() / 2).asDegrees()
1187 tractDec0 = (tractCenter[1] - tractBbox.getHeight() / 2).asDegrees()
1188 tractDec1 = (tractCenter[1] + tractBbox.getHeight() / 2).asDegrees()
1189 tractLimitsDict[tract] = {
1190 "ras": [tractRa0, tractRa1, tractRa1, tractRa0, tractRa0],
1191 "decs": [tractDec0, tractDec0, tractDec1, tractDec1, tractDec0],
1192 "center": [tractCenter[0].asDegrees(), tractCenter[1].asDegrees()],
1193 }
1194
1195 return tractLimitsDict
1196
1197
1198def getMinMaxLimits(limitsDict):
1199 """Derive the min and max axis limits of points in limitsDict.
1200
1201 Parameters
1202 ----------
1203 limitsDict : `dict` [`dict`]
1204 A dictionary keyed on any id. Each entry includes a `dict`
1205 keyed on "ras" and "decs" including (at least the minimum
1206 and maximum) RA and Dec values in units of degrees.
1207
1208 Returns
1209 -------
1210 xLimMin, xLimMax, yLimMin, yLimMax : `tuple` [`float`]
1211 The min and max values for the x and y-axis limits, respectively.
1212 """
1213 xLimMin, yLimMin = 1e12, 1e12
1214 xLimMax, yLimMax = -1e12, -1e12
1215 for limitId, limits in limitsDict.items():
1216 xLimMin = min(xLimMin, min(limits["ras"]))
1217 xLimMax = max(xLimMax, max(limits["ras"]))
1218 yLimMin = min(yLimMin, min(limits["decs"]))
1219 yLimMax = max(yLimMax, max(limits["decs"]))
1220
1221 return xLimMin, xLimMax, yLimMin, yLimMax
1222
1223
1224def derivePlotLimits(limitsDict, raToDecLimitRatio=1.0, buffFrac=0.0):
1225 """Derive the axis limits to encompass all points in limitsDict.
1226
1227 Parameters
1228 ----------
1229 limitsDict : `dict` [`dict`]
1230 A dictionary keyed on any id. Each entry includes a `dict`
1231 keyed on "ras" and "decs" including (at least the minimum
1232 and maximum) RA and Dec values in units of degrees.
1233 raToDecLimitRatio : `float`, optional
1234 The aspect ratio between RA and Dec to set the plot limits to. This
1235 is to namely to set this ratio to that of the focal plane (i.e. such
1236 that a square detector appears as a square), but any aspect ratio can,
1237 in principle, be requested.
1238
1239 Returns
1240 -------
1241 xlim, ylim : `tuple` [`float`]
1242 Two tuples containing the derived min and max values for the x and
1243 y-axis limits (in degrees), respectively.
1244 """
1245 xLimMin, xLimMax, yLimMin, yLimMax = getMinMaxLimits(limitsDict)
1246
1247 xDelta0 = xLimMax - xLimMin
1248 yDelta0 = yLimMax - yLimMin
1249 if raToDecLimitRatio is None:
1250 padFrac = 0.05
1251 xlim = xLimMax + padFrac * xDelta0, xLimMin - padFrac * xDelta0
1252 ylim = yLimMin - padFrac * yDelta0, yLimMax + padFrac * yDelta0
1253 return xlim, ylim
1254
1255 if raToDecLimitRatio == 1.0:
1256 if xDelta0 > yDelta0:
1257 xLimMin -= buffFrac * yDelta0
1258 xLimMax += buffFrac * yDelta0
1259 else:
1260 yLimMin -= buffFrac * yDelta0
1261 yLimMax += buffFrac * yDelta0
1262 xLimMin, xLimMax, yLimMin, yLimMax = setLimitsToEqualRatio(xLimMin, xLimMax, yLimMin, yLimMax)
1263 else:
1264 xLimMin -= buffFrac * xDelta0
1265 xLimMax += buffFrac * xDelta0
1266 yLimMin -= buffFrac * yDelta0
1267 yLimMax += buffFrac * yDelta0
1268 xLimMin, xLimMax, yLimMin, yLimMax = setLimitsToEqualRatio(xLimMin, xLimMax, yLimMin, yLimMax)
1269 xDelta = xLimMax - xLimMin
1270 yDelta = yLimMax - yLimMin
1271 if raToDecLimitRatio > 1.0:
1272 if yDelta0 > xDelta:
1273 xMid = xLimMin + 0.5 * (xDelta)
1274 xLimMin = xMid - 0.5 * yDelta * raToDecLimitRatio
1275 xLimMax = xMid + 0.5 * yDelta * raToDecLimitRatio
1276 else:
1277 yMid = yLimMin + 0.5 * (yDelta)
1278 yLimMin = yMid - 0.5 * xDelta / raToDecLimitRatio
1279 yLimMax = yMid + 0.5 * xDelta / raToDecLimitRatio
1280 else:
1281 if xDelta0 > yDelta0:
1282 yMid = yLimMin + 0.5 * (yDelta)
1283 yLimMin = yMid - 0.5 * xDelta / raToDecLimitRatio
1284 yLimMax = yMid + 0.5 * xDelta / raToDecLimitRatio
1285 else:
1286 xMid = xLimMin + 0.5 * (xDelta)
1287 xLimMin = xMid - 0.5 * yDelta * raToDecLimitRatio
1288 xLimMax = xMid + 0.5 * yDelta * raToDecLimitRatio
1289 xlim = xLimMax, xLimMin
1290 ylim = yLimMin, yLimMax
1291 return xlim, ylim
1292
1293
1294def setLimitsToEqualRatio(xMin, xMax, yMin, yMax):
1295 """For a given set of x/y min/max, redefine to have equal aspect ratio.
1296
1297 The limits are extended on both ends such that the central value is
1298 preserved.
1299
1300 Parameters
1301 ----------
1302 xMin, xMax, yMin, yMax : `float`
1303 The min/max values of the x/y ranges for which to match in dynamic
1304 range while preserving the central values.
1305
1306 Returns
1307 -------
1308 xMin, xMax, yMin, yMax : `float`
1309 The adjusted min/max values of the x/y ranges with equal aspect ratios.
1310 """
1311 xDelta = xMax - xMin
1312 yDelta = yMax - yMin
1313 deltaDiff = yDelta - xDelta
1314 if deltaDiff > 0:
1315 xMin -= 0.5 * deltaDiff
1316 xMax += 0.5 * deltaDiff
1317 elif deltaDiff < 0:
1318 yMin -= 0.5 * abs(deltaDiff)
1319 yMax += 0.5 * abs(deltaDiff)
1320 return xMin, xMax, yMin, yMax
1321
1322
1324 ccdKey,
1325 ccdId,
1326 visit,
1327 visitSummary=None,
1328 butler=None,
1329 imageDatasetType="calexp",
1330 doLogWarn=True,
1331 missingVisitSummaryRows=None,
1332):
1333 """Compute the RA/Dec corners lists for a given detector in a visit."""
1334 raCorners, decCorners = None, None
1335 if visitSummary is not None:
1336 row = visitSummary.find(ccdId)
1337 if row is None:
1338 if doLogWarn:
1339 if missingVisitSummaryRows is not None:
1340 missingVisitSummaryRows.setdefault(visit, set()).add(ccdId)
1341 else:
1342 logger.warning(
1343 "No row found for %d in visit summary table for visit %d. Skipping and continuing...",
1344 ccdId,
1345 visit,
1346 )
1347 else:
1348 raCorners = list(row["raCorners"])
1349 decCorners = list(row["decCorners"])
1350 else:
1351 if butler is None:
1352 raise RuntimeError("A butler instance is required when visitSummary is not provided")
1353 try:
1354 if imageDatasetType == "raw":
1355 dataId = {"exposure": visit, ccdKey: ccdId}
1356 # Raw exposures may not have a bbox component.
1357 exposure = butler.get(imageDatasetType, dataId)
1358 wcs = exposure.getWcs()
1359 bbox = exposure.getBBox()
1360 else:
1361 dataId = {"visit": visit, ccdKey: ccdId}
1362 try:
1363 wcs = butler.get(f"{imageDatasetType}.wcs", dataId)
1364 bbox = butler.get(f"{imageDatasetType}.bbox", dataId)
1365 except LookupError:
1366 # Some dataset types are only available as full exposures.
1367 exposure = butler.get(imageDatasetType, dataId)
1368 wcs = exposure.getWcs()
1369 bbox = exposure.getBBox()
1370 if wcs is None or bbox is None:
1371 if doLogWarn:
1372 logger.warning(
1373 "WCS or BBox is None for datasetType=%s visit=%s %s=%s. Skipping and continuing...",
1374 imageDatasetType,
1375 visit,
1376 ccdKey,
1377 ccdId,
1378 )
1379 return None, None
1380 raCorners, decCorners = bboxToRaDec(bbox, wcs)
1381 except LookupError as e:
1382 if doLogWarn:
1383 logger.warning("%s Skipping and continuing...", e)
1384 except Exception as e:
1385 if doLogWarn:
1386 logger.warning("%s Skipping and continuing...", e)
1387
1388 return raCorners, decCorners
1389
1390
1391def getBand(visitSummary=None, butler=None, visit=None):
1392 """Determine band and physical filter for given visit.
1393
1394 Parameters
1395 ----------
1396 visitSummary : `lsst.afw.table.ExposureCatalog` or `None`, optional
1397 The visitSummary table for the visit for which to determine the band.
1398 butler : `lsst.daf.butler.Butler` or `None`, optional
1399 The butler from which to look up the Dimension Records. Only needed
1400 if ``visitSummary`` is `None`.
1401 visit : `int` or `None, optional
1402 The visit number for which to determine the band. Only needed
1403 if ``visitSummary`` is `None`.
1404
1405 Returns
1406 -------
1407 band, physicalFilter : `str`
1408 The band and physical filter for the given visit.
1409 """
1410 if visitSummary is not None:
1411 band = visitSummary[0]["band"]
1412 physicalFilter = visitSummary[0]["physical_filter"]
1413 else:
1414 if butler is None:
1415 raise RuntimeError("A butler instance is required when visitSummary is not provided")
1416 record = list(butler.registry.queryDimensionRecords("band", visit=visit))[0]
1417 band = record.name
1418 record = list(butler.registry.queryDimensionRecords("physical_filter", visit=visit))[0]
1419 physicalFilter = record.name
1420 return band, physicalFilter
1421
1422
1423if __name__ == "__main__":
1424 parser = argparse.ArgumentParser()
1425 butlerGroup = parser.add_argument_group("Repository and Collections")
1426 selectionGroup = parser.add_argument_group("Selection Filters")
1427 plotGroup = parser.add_argument_group("Plot Content and Layout")
1428 outputGroup = parser.add_argument_group("File Output")
1429 dataGroup = parser.add_argument_group("Dataset Lookup")
1430 runtimeGroup = parser.add_argument_group("Runtime and Logging")
1431
1432 # Repository and Collections
1433 butlerGroup.add_argument(
1434 "repo",
1435 type=str,
1436 help="URI or path to an existing data repository root or configuration file.",
1437 )
1438 butlerGroup.add_argument(
1439 "--collections",
1440 type=str,
1441 nargs="+",
1442 help="Whitespace-separated list of collection names for Butler instantiation.",
1443 metavar=("COLLECTION1", "COLLECTION2"),
1444 required=True,
1445 )
1446 butlerGroup.add_argument(
1447 "--skymapName",
1448 default=None,
1449 help="Name of the skymap for the collection.",
1450 )
1451
1452 # Selection Filters
1453 selectionGroup.add_argument(
1454 "--radec",
1455 type=float,
1456 nargs=2,
1457 default=None,
1458 metavar=("RA", "DEC"),
1459 help=(
1460 "RA and Dec (in degrees) of a coordinate to mark on the plot with a star marker; "
1461 "also limits dataset queries to visits whose sky region overlaps this point."
1462 ),
1463 )
1464 selectionGroup.add_argument(
1465 "--tracts",
1466 type=int,
1467 nargs="+",
1468 default=None,
1469 help="Whitespace-separated list of tracts to constrain search for visit overlap.",
1470 metavar=("TRACT1", "TRACT2"),
1471 )
1472 selectionGroup.add_argument(
1473 "--patches",
1474 type=int,
1475 nargs="+",
1476 default=None,
1477 help="Whitespace-separated list of patches to constrain search for visit overlap.",
1478 metavar=("PATCH1", "PATCH2"),
1479 )
1480 selectionGroup.add_argument(
1481 "--visits",
1482 type=int,
1483 nargs="+",
1484 default=None,
1485 help="Whitespace-separated list of visits to include.",
1486 metavar=("VISIT1", "VISIT2"),
1487 )
1488 selectionGroup.add_argument(
1489 "--physicalFilters",
1490 type=str,
1491 nargs="+",
1492 default=None,
1493 help="Whitespace-separated list of physical filter names to constrain search for visits.",
1494 metavar=("PHYSICAL_FILTER1", "PHYSICAL_FILTER2"),
1495 )
1496 selectionGroup.add_argument(
1497 "--bands",
1498 type=str,
1499 nargs="+",
1500 default=None,
1501 help="Whitespace-separated list of canonical band names to constrain search for visits.",
1502 metavar=("BAND1", "BAND2"),
1503 )
1504 selectionGroup.add_argument(
1505 "-c",
1506 "--ccds",
1507 nargs="+",
1508 type=int,
1509 default=None,
1510 help="Whitespace-separated list of CCDs to show.",
1511 metavar=("CCD1", "CCD2"),
1512 )
1513 selectionGroup.add_argument(
1514 "--maxVisits",
1515 type=int,
1516 default=None,
1517 help=(
1518 "Maximum number of unique visits to include when --visits is not provided; "
1519 "use 0 to skip visit plotting and show tract-only output."
1520 ),
1521 )
1522 selectionGroup.add_argument(
1523 "--visitVetoFile",
1524 type=str,
1525 default=None,
1526 help="Full path to a single-column file containing a list of visits to veto.",
1527 )
1528 selectionGroup.add_argument(
1529 "--minOverlapFraction",
1530 type=float,
1531 default=None,
1532 help="Minimum fraction of detectors that overlap any tract for a visit to be included.",
1533 )
1534
1535 # Plot Content and Layout
1536 plotGroup.add_argument(
1537 "-p",
1538 "--showPatch",
1539 action="store_true",
1540 default=False,
1541 help="Show the patch boundaries.",
1542 )
1543 plotGroup.add_argument(
1544 "--showPatchSelectedTractsOnly",
1545 action="store_true",
1546 default=False,
1547 help="When showing patches, only draw patch boundaries for the tracts passed to --tracts.",
1548 )
1549 plotGroup.add_argument(
1550 "--showCcds",
1551 action="store_true",
1552 default=False,
1553 help="Show CCD ID numbers on the output image, suppressing overlapping labels.",
1554 )
1555 plotGroup.add_argument(
1556 "--showCcdsAll",
1557 action="store_true",
1558 default=False,
1559 help="Show all ccd ID numbers on output image (without suppressing overlaps). "
1560 "Takes precedence over --showCcds when both are set.",
1561 )
1562 plotGroup.add_argument(
1563 "--plotFailsOnly",
1564 action="store_true",
1565 default=False,
1566 help=(
1567 "Plot only detector outlines for visits that have raw data but no "
1568 "processed image (i.e. failed calibration). Outlines are drawn "
1569 "from the raw WCS. Output filename is suffixed with '_failed' automatically."
1570 ),
1571 )
1572 plotGroup.add_argument(
1573 "--showRawOutlines",
1574 action="store_true",
1575 default=False,
1576 help=(
1577 "Overlay raw detector footprints as dotted outlines on the plot. "
1578 "Ignored when --plotFailsOnly is set."
1579 ),
1580 )
1581 plotGroup.add_argument(
1582 "--trimToTracts",
1583 action="store_true",
1584 default=False,
1585 help=(
1586 "Set plot limits based on the extent of the tracts specified via --tracts "
1587 "(or all overlapping tracts if --tracts is not given), rather than the visit footprints."
1588 ),
1589 )
1590 plotGroup.add_argument(
1591 "--trimToOverlappingTracts",
1592 action="store_true",
1593 default=False,
1594 help=(
1595 "Set plot limits based on the extent of all tracts overlapping the plotted visits, "
1596 "regardless of --tracts. Takes precedence over --trimToTracts when both are set."
1597 ),
1598 )
1599 plotGroup.add_argument(
1600 "--doUnscaledLimitRatio",
1601 action="store_true",
1602 default=False,
1603 help="Let axis limits get set by sky coordinate range without scaling to focal "
1604 "plane based projection (ignored if --forceScaledLimitRatio is passed).",
1605 )
1606 plotGroup.add_argument(
1607 "--forceScaledLimitRatio",
1608 action="store_true",
1609 default=False,
1610 help="Force the axis limit scaling to focal plane based projection (takes "
1611 "precedence over --doUnscaledLimitRatio.",
1612 )
1613 plotGroup.add_argument(
1614 "--maxVisitsForLegend",
1615 type=int,
1616 default=20,
1617 help="Maximum number of visits to include in the legend before switching to a colorbar.",
1618 )
1619 plotGroup.add_argument(
1620 "--useRubinPlotStyle",
1621 action="store_true",
1622 default=False,
1623 help="Use Rubin publication plotting style and per-band colors/linestyles for detector outlines.",
1624 )
1625
1626 # File Output
1627 outputGroup.add_argument(
1628 "--saveFile",
1629 type=str,
1630 default="showVisitSkyMap.png",
1631 help="Filename to write the plot to.",
1632 )
1633 outputGroup.add_argument(
1634 "--dpi",
1635 type=int,
1636 default=150,
1637 help="DPI used when writing output via --saveFile.",
1638 )
1639
1640 # Dataset Lookup
1641 dataGroup.add_argument(
1642 "--ccdKey",
1643 default="detector",
1644 help="Data ID key name for the CCD (default: 'detector').",
1645 )
1646 dataGroup.add_argument(
1647 "--imageDatasetType",
1648 type=str,
1649 default=None,
1650 help=(
1651 "Image dataset type used for visit discovery and WCS/bbox fallback; "
1652 "defaults to commonly used detector storage types."
1653 ),
1654 )
1655 dataGroup.add_argument(
1656 "--visitSummaryDatasetType",
1657 type=str,
1658 default=None,
1659 help="Visit summary dataset type to use; defaults to commonly used visit summary types.",
1660 )
1661
1662 # Runtime and Logging
1663 runtimeGroup.add_argument(
1664 "--logLevel",
1665 default="INFO",
1666 choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
1667 help="Logging level (default: INFO).",
1668 )
1669
1670 args = parser.parse_args()
1671 logging.basicConfig(level=getattr(logging, args.logLevel), format="%(levelname)s:%(name)s: %(message)s")
1672 logging.getLogger("numexpr.utils").setLevel(logging.WARNING)
1673 main(
1674 args.repo,
1675 args.collections,
1676 skymapName=args.skymapName,
1677 radec=args.radec,
1678 tracts=args.tracts,
1679 patches=args.patches,
1680 visits=args.visits,
1681 physicalFilters=args.physicalFilters,
1682 bands=args.bands,
1683 ccds=args.ccds,
1684 maxVisits=args.maxVisits,
1685 visitVetoFile=args.visitVetoFile,
1686 minOverlapFraction=args.minOverlapFraction,
1687 showPatch=args.showPatch,
1688 showPatchSelectedTractsOnly=args.showPatchSelectedTractsOnly,
1689 showCcds=args.showCcds,
1690 showCcdsAll=args.showCcdsAll,
1691 plotFailsOnly=args.plotFailsOnly,
1692 showRawOutlines=args.showRawOutlines,
1693 trimToTracts=args.trimToTracts,
1694 trimToOverlappingTracts=args.trimToOverlappingTracts,
1695 doUnscaledLimitRatio=args.doUnscaledLimitRatio,
1696 forceScaledLimitRatio=args.forceScaledLimitRatio,
1697 maxVisitsForLegend=args.maxVisitsForLegend,
1698 useRubinPlotStyle=args.useRubinPlotStyle,
1699 saveFile=args.saveFile,
1700 dpi=args.dpi,
1701 ccdKey=args.ccdKey,
1702 imageDatasetType=args.imageDatasetType,
1703 visitSummaryDatasetType=args.visitSummaryDatasetType,
1704 )
getBand(visitSummary=None, butler=None, visit=None)
derivePlotLimits(limitsDict, raToDecLimitRatio=1.0, buffFrac=0.0)
sanitizeTractList(skymap, tractList)
bboxToRaDec(bbox, wcs)
getBandPlotStyle(band, defaultColor)
getTractLimitsDict(skymap, tractList)
getVisitSummaryForVisit(butler, visit, visitSummaryDatasetType=None)
configureBandStyle(useRubinPlotStyle=False)
queryImageDatasets(butler, whereStr, imageDatasetType=None)
makeWhereInStr(parameterName, parameterList, parameterType)
main(repo, collections, skymapName=None, radec=None, tracts=None, patches=None, visits=None, physicalFilters=None, bands=None, ccds=None, maxVisits=None, visitVetoFile=None, minOverlapFraction=None, showPatch=False, showPatchSelectedTractsOnly=False, showCcds=False, showCcdsAll=False, plotFailsOnly=False, showRawOutlines=False, trimToTracts=False, trimToOverlappingTracts=False, doUnscaledLimitRatio=False, forceScaledLimitRatio=False, maxVisitsForLegend=20, useRubinPlotStyle=False, saveFile=None, dpi=150, ccdKey="detector", imageDatasetType=None, visitSummaryDatasetType=None)
getDetRaDecCorners(ccdKey, ccdId, visit, visitSummary=None, butler=None, imageDatasetType="calexp", doLogWarn=True, missingVisitSummaryRows=None)
getValueAtPercentile(values, percentile=0.5)
setLimitsToEqualRatio(xMin, xMax, yMin, yMax)
getMinMaxLimits(limitsDict)
get_cmap(n, name="gist_rainbow")