stdpipe 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
stdpipe/plots.py
ADDED
|
@@ -0,0 +1,1582 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
from matplotlib.patches import Circle
|
|
4
|
+
from matplotlib import rcParams
|
|
5
|
+
import builtins
|
|
6
|
+
|
|
7
|
+
from astropy.stats import mad_std
|
|
8
|
+
from astropy.visualization import simple_norm, ImageNormalize
|
|
9
|
+
from astropy.visualization.stretch import HistEqStretch
|
|
10
|
+
from astropy.convolution import Gaussian2DKernel, convolve, convolve_fft
|
|
11
|
+
|
|
12
|
+
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
|
13
|
+
from scipy.stats import binned_statistic_2d
|
|
14
|
+
from scipy.spatial import Voronoi
|
|
15
|
+
|
|
16
|
+
from . import photometry
|
|
17
|
+
|
|
18
|
+
# Optional powerbin import
|
|
19
|
+
try:
|
|
20
|
+
import powerbin
|
|
21
|
+
|
|
22
|
+
HAS_POWERBIN = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
HAS_POWERBIN = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def colorbar(obj=None, ax=None, size="5%", pad=0.1):
|
|
28
|
+
should_restore = False
|
|
29
|
+
|
|
30
|
+
if ax is None and obj is not None:
|
|
31
|
+
ax = obj.axes
|
|
32
|
+
elif ax is None:
|
|
33
|
+
ax = plt.gca()
|
|
34
|
+
# should_restore = True
|
|
35
|
+
|
|
36
|
+
# create an axes on the right side of ax. The width of cax will be 5%
|
|
37
|
+
# of ax and the padding between cax and ax will be fixed at 0.05 inch.
|
|
38
|
+
divider = make_axes_locatable(ax)
|
|
39
|
+
cax = divider.append_axes("right", size=size, pad=pad)
|
|
40
|
+
|
|
41
|
+
ax.get_figure().colorbar(obj, cax=cax)
|
|
42
|
+
|
|
43
|
+
# if should_restore:
|
|
44
|
+
ax.get_figure().sca(ax)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def imshow(
|
|
48
|
+
image,
|
|
49
|
+
qq=None,
|
|
50
|
+
mask=None,
|
|
51
|
+
show_colorbar=True,
|
|
52
|
+
show_axis=True,
|
|
53
|
+
stretch='linear',
|
|
54
|
+
r0=None,
|
|
55
|
+
ax=None,
|
|
56
|
+
max_plot_size=4096,
|
|
57
|
+
fast=True,
|
|
58
|
+
xlim=None,
|
|
59
|
+
ylim=None,
|
|
60
|
+
**kwargs,
|
|
61
|
+
):
|
|
62
|
+
"""Display a 2D image with percentile-based intensity scaling.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
image : ndarray
|
|
67
|
+
2D array to display.
|
|
68
|
+
qq : list of float, optional
|
|
69
|
+
Two-element ``[low, high]`` percentile range for intensity normalization.
|
|
70
|
+
Default is ``[0.5, 99.5]``. Overridden by explicit ``vmin`` / ``vmax``.
|
|
71
|
+
mask : ndarray of bool, optional
|
|
72
|
+
Boolean mask; masked pixels are excluded from intensity normalization.
|
|
73
|
+
show_colorbar : bool, optional
|
|
74
|
+
If True, display a colorbar alongside the image.
|
|
75
|
+
show_axis : bool, optional
|
|
76
|
+
If True, display axis ticks and labels.
|
|
77
|
+
stretch : str, optional
|
|
78
|
+
Intensity stretch: ``'linear'``, ``'log'``, ``'asinh'``, ``'histeq'``,
|
|
79
|
+
or any stretch supported by Astropy visualization.
|
|
80
|
+
r0 : float, optional
|
|
81
|
+
Gaussian smoothing sigma in pixels applied before display.
|
|
82
|
+
ax : matplotlib.axes.Axes, optional
|
|
83
|
+
Axes to draw on; defaults to the current axes.
|
|
84
|
+
max_plot_size : int or None, optional
|
|
85
|
+
Images larger than this (in pixels) are downscaled for rendering.
|
|
86
|
+
Set to None to disable. Default is 4096.
|
|
87
|
+
fast : bool, optional
|
|
88
|
+
If True, use faster approximate methods for large images (subsampled
|
|
89
|
+
percentiles, float32, FFT convolution).
|
|
90
|
+
xlim : tuple of float, optional
|
|
91
|
+
``(xmin, xmax)`` region selection in original image coordinates.
|
|
92
|
+
ylim : tuple of float, optional
|
|
93
|
+
``(ymin, ymax)`` region selection in original image coordinates.
|
|
94
|
+
**kwargs
|
|
95
|
+
Additional keyword arguments passed to :func:`matplotlib.pyplot.imshow`.
|
|
96
|
+
"""
|
|
97
|
+
if ax is None:
|
|
98
|
+
ax = plt.gca()
|
|
99
|
+
|
|
100
|
+
# Store original shape for coordinate system
|
|
101
|
+
orig_shape = image.shape
|
|
102
|
+
|
|
103
|
+
# STEP 0: Region selection (FIRST, before any processing)
|
|
104
|
+
if xlim is not None or ylim is not None:
|
|
105
|
+
x0 = int(np.floor(xlim[0])) if xlim is not None else 0
|
|
106
|
+
x1 = int(np.ceil(xlim[1])) + 1 if xlim is not None else orig_shape[1]
|
|
107
|
+
y0 = int(np.floor(ylim[0])) if ylim is not None else 0
|
|
108
|
+
y1 = int(np.ceil(ylim[1])) + 1 if ylim is not None else orig_shape[0]
|
|
109
|
+
|
|
110
|
+
# Clip to image bounds
|
|
111
|
+
x0 = max(0, min(x0, orig_shape[1]))
|
|
112
|
+
x1 = max(0, min(x1, orig_shape[1]))
|
|
113
|
+
y0 = max(0, min(y0, orig_shape[0]))
|
|
114
|
+
y1 = max(0, min(y1, orig_shape[0]))
|
|
115
|
+
|
|
116
|
+
# Extract region at full resolution
|
|
117
|
+
image = image[y0:y1, x0:x1]
|
|
118
|
+
if mask is not None:
|
|
119
|
+
mask = mask[y0:y1, x0:x1]
|
|
120
|
+
|
|
121
|
+
region_offset = (x0, y0)
|
|
122
|
+
region_shape = (y1 - y0, x1 - x0)
|
|
123
|
+
else:
|
|
124
|
+
region_offset = (0, 0)
|
|
125
|
+
region_shape = orig_shape
|
|
126
|
+
|
|
127
|
+
# OPTIMIZATION 1: Downscale large images for matplotlib
|
|
128
|
+
if fast and max_plot_size is not None and max(image.shape) > max_plot_size:
|
|
129
|
+
scale = max_plot_size / max(image.shape)
|
|
130
|
+
from scipy.ndimage import zoom
|
|
131
|
+
|
|
132
|
+
image = zoom(image, scale, order=1) # bilinear interpolation
|
|
133
|
+
if mask is not None:
|
|
134
|
+
mask = zoom(mask.astype(np.uint8), scale, order=0).astype(bool)
|
|
135
|
+
|
|
136
|
+
# OPTIMIZATION 2: Use float32 instead of float64 for memory efficiency
|
|
137
|
+
if r0 is not None and r0 > 0:
|
|
138
|
+
# Need to convert for smoothing operation
|
|
139
|
+
image = image.astype(np.float32)
|
|
140
|
+
elif not np.issubdtype(image.dtype, np.floating):
|
|
141
|
+
# For integer images, convert for proper display
|
|
142
|
+
image = image.astype(np.float32)
|
|
143
|
+
else:
|
|
144
|
+
# Already floating point, use as-is
|
|
145
|
+
image = np.asarray(image)
|
|
146
|
+
|
|
147
|
+
# OPTIMIZATION 3: Fast finite check for large images
|
|
148
|
+
if fast and image.size > 10_000_000:
|
|
149
|
+
# Quick check: are there ANY non-finite values in a sample?
|
|
150
|
+
if not np.all(np.isfinite(image.flat[:10000])):
|
|
151
|
+
good_idx = np.isfinite(image)
|
|
152
|
+
else:
|
|
153
|
+
good_idx = np.ones(image.shape, dtype=bool)
|
|
154
|
+
else:
|
|
155
|
+
good_idx = np.isfinite(image)
|
|
156
|
+
|
|
157
|
+
if mask is not None:
|
|
158
|
+
good_idx &= ~mask
|
|
159
|
+
|
|
160
|
+
if np.sum(good_idx):
|
|
161
|
+
# OPTIMIZATION 4: FFT convolution for large images
|
|
162
|
+
if r0 is not None and r0 > 0:
|
|
163
|
+
kernel = Gaussian2DKernel(r0)
|
|
164
|
+
# Use FFT convolution for large images (much faster)
|
|
165
|
+
if fast and image.size > 1_000_000:
|
|
166
|
+
from astropy.convolution import convolve_fft
|
|
167
|
+
|
|
168
|
+
image = convolve_fft(
|
|
169
|
+
image, kernel, mask=mask, boundary='extend', nan_treatment='fill', fill_value=0
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
image = convolve(image, kernel, mask=mask, boundary='extend')
|
|
173
|
+
|
|
174
|
+
if qq is None and 'vmin' not in kwargs and 'vmax' not in kwargs:
|
|
175
|
+
# Sane defaults for quantiles if no manual limits provided
|
|
176
|
+
qq = [0.5, 99.5]
|
|
177
|
+
|
|
178
|
+
# OPTIMIZATION 5: Subsample for percentiles on large images
|
|
179
|
+
if qq is not None:
|
|
180
|
+
# Presence of qq quantiles overwrites vmin/vmax even if they are present
|
|
181
|
+
max_pixels = 1_000_000
|
|
182
|
+
n_good = np.sum(good_idx)
|
|
183
|
+
if fast and n_good > max_pixels:
|
|
184
|
+
# Random subsample of good pixels for percentile calculation
|
|
185
|
+
good_coords = np.where(good_idx)
|
|
186
|
+
sample_idx = np.random.choice(len(good_coords[0]), max_pixels, replace=False)
|
|
187
|
+
sample_values = image[good_coords[0][sample_idx], good_coords[1][sample_idx]]
|
|
188
|
+
kwargs['vmin'], kwargs['vmax'] = np.percentile(sample_values, qq)
|
|
189
|
+
else:
|
|
190
|
+
kwargs['vmin'], kwargs['vmax'] = np.percentile(image[good_idx], qq)
|
|
191
|
+
|
|
192
|
+
if not 'interpolation' in kwargs:
|
|
193
|
+
# Rough heuristic to choose interpolation method based on image dimensions
|
|
194
|
+
if image.shape[0] < 300 and image.shape[1] < 300:
|
|
195
|
+
kwargs['interpolation'] = 'nearest'
|
|
196
|
+
else:
|
|
197
|
+
kwargs['interpolation'] = 'bicubic'
|
|
198
|
+
|
|
199
|
+
# OPTIMIZATION 6: Optimize stretch (especially histeq mode)
|
|
200
|
+
if stretch and stretch != 'linear':
|
|
201
|
+
if stretch == 'histeq':
|
|
202
|
+
# Use only valid pixels for histogram, avoid creating multiple copies
|
|
203
|
+
if 'vmin' in kwargs or 'vmax' in kwargs:
|
|
204
|
+
vmin = kwargs.get('vmin', -np.inf)
|
|
205
|
+
vmax = kwargs.get('vmax', np.inf)
|
|
206
|
+
valid_mask = (image >= vmin) & (image <= vmax) & good_idx
|
|
207
|
+
data = image[valid_mask]
|
|
208
|
+
else:
|
|
209
|
+
data = image[good_idx]
|
|
210
|
+
|
|
211
|
+
kwargs['norm'] = ImageNormalize(
|
|
212
|
+
stretch=HistEqStretch(data),
|
|
213
|
+
vmin=kwargs.pop('vmin', None),
|
|
214
|
+
vmax=kwargs.pop('vmax', None),
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
kwargs['norm'] = simple_norm(
|
|
218
|
+
image,
|
|
219
|
+
stretch,
|
|
220
|
+
min_cut=kwargs.pop('vmin', None),
|
|
221
|
+
max_cut=kwargs.pop('vmax', None),
|
|
222
|
+
power=2,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# CRITICAL: Preserve coordinate system (including region offset)
|
|
226
|
+
if 'extent' not in kwargs:
|
|
227
|
+
x0_extent = region_offset[0] - 0.5
|
|
228
|
+
y0_extent = region_offset[1] - 0.5
|
|
229
|
+
x1_extent = x0_extent + region_shape[1]
|
|
230
|
+
y1_extent = y0_extent + region_shape[0]
|
|
231
|
+
|
|
232
|
+
# extent = [left, right, bottom, top]
|
|
233
|
+
# For origin='upper' (default): y increases downward, so bottom=y1, top=y0
|
|
234
|
+
# For origin='lower': y increases upward, so bottom=y0, top=y1
|
|
235
|
+
origin = kwargs.get('origin', rcParams.get('image.origin', 'upper'))
|
|
236
|
+
if origin == 'lower':
|
|
237
|
+
kwargs['extent'] = [x0_extent, x1_extent, y0_extent, y1_extent]
|
|
238
|
+
else:
|
|
239
|
+
kwargs['extent'] = [x0_extent, x1_extent, y1_extent, y0_extent]
|
|
240
|
+
|
|
241
|
+
img = ax.imshow(image, **kwargs)
|
|
242
|
+
if not show_axis:
|
|
243
|
+
ax.set_axis_off()
|
|
244
|
+
else:
|
|
245
|
+
ax.set_axis_on()
|
|
246
|
+
if show_colorbar:
|
|
247
|
+
colorbar(img, ax=ax)
|
|
248
|
+
else:
|
|
249
|
+
# Mimic the extension of scaling limits if they are equal
|
|
250
|
+
clim = img.get_clim()
|
|
251
|
+
|
|
252
|
+
if clim[0] == clim[1]:
|
|
253
|
+
img.set_clim(clim[0] - 0.1, clim[1] + 0.1)
|
|
254
|
+
|
|
255
|
+
return img
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def binned_map(
|
|
259
|
+
x,
|
|
260
|
+
y,
|
|
261
|
+
value,
|
|
262
|
+
bins=16,
|
|
263
|
+
statistic='mean',
|
|
264
|
+
qq=[0.5, 97.5],
|
|
265
|
+
color=None,
|
|
266
|
+
show_colorbar=True,
|
|
267
|
+
show_axis=True,
|
|
268
|
+
show_dots=False,
|
|
269
|
+
ax=None,
|
|
270
|
+
range=None,
|
|
271
|
+
**kwargs,
|
|
272
|
+
):
|
|
273
|
+
"""Plot statistical estimators binned onto a regular grid.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
x : array_like
|
|
278
|
+
Abscissae of the data points.
|
|
279
|
+
y : array_like
|
|
280
|
+
Ordinates of the data points.
|
|
281
|
+
value : array_like
|
|
282
|
+
Values to aggregate.
|
|
283
|
+
bins : int, optional
|
|
284
|
+
Number of bins per axis.
|
|
285
|
+
statistic : str or callable, optional
|
|
286
|
+
Aggregation statistic: ``'mean'``, ``'median'``, or a callable.
|
|
287
|
+
qq : list of float, optional
|
|
288
|
+
``[low, high]`` percentile range for color scaling. Default ``[0.5, 97.5]``.
|
|
289
|
+
Overridden by explicit ``vmin`` / ``vmax``.
|
|
290
|
+
color : color, optional
|
|
291
|
+
Color used for the data-point overlay (requires ``show_dots=True``).
|
|
292
|
+
show_colorbar : bool, optional
|
|
293
|
+
If True, display a colorbar.
|
|
294
|
+
show_axis : bool, optional
|
|
295
|
+
If True, display axis ticks and labels.
|
|
296
|
+
show_dots : bool, optional
|
|
297
|
+
If True, overlay the raw data point positions.
|
|
298
|
+
range : list of list, optional
|
|
299
|
+
Data range ``[[xmin, xmax], [ymin, ymax]]``.
|
|
300
|
+
ax : matplotlib.axes.Axes, optional
|
|
301
|
+
Axes to draw on; defaults to the current axes.
|
|
302
|
+
**kwargs
|
|
303
|
+
Additional keyword arguments passed to :func:`matplotlib.pyplot.imshow`.
|
|
304
|
+
"""
|
|
305
|
+
gmag0, xe, ye, binnumbers = binned_statistic_2d(
|
|
306
|
+
x, y, value, bins=bins, statistic=statistic, range=range
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
vmin1, vmax1 = np.percentile(gmag0[np.isfinite(gmag0)], qq)
|
|
310
|
+
if not 'vmin' in kwargs:
|
|
311
|
+
kwargs['vmin'] = vmin1
|
|
312
|
+
if not 'vmax' in kwargs:
|
|
313
|
+
kwargs['vmax'] = vmax1
|
|
314
|
+
|
|
315
|
+
if ax is None:
|
|
316
|
+
ax = plt.gca()
|
|
317
|
+
|
|
318
|
+
if not 'aspect' in kwargs:
|
|
319
|
+
kwargs['aspect'] = 'auto'
|
|
320
|
+
|
|
321
|
+
im = ax.imshow(
|
|
322
|
+
gmag0.T,
|
|
323
|
+
origin='lower',
|
|
324
|
+
extent=[xe[0], xe[-1], ye[0], ye[-1]],
|
|
325
|
+
interpolation='nearest',
|
|
326
|
+
**kwargs,
|
|
327
|
+
)
|
|
328
|
+
if show_colorbar:
|
|
329
|
+
colorbar(im, ax=ax)
|
|
330
|
+
|
|
331
|
+
if not show_axis:
|
|
332
|
+
ax.set_axis_off()
|
|
333
|
+
else:
|
|
334
|
+
ax.set_axis_on()
|
|
335
|
+
|
|
336
|
+
if show_dots:
|
|
337
|
+
ax.set_autoscale_on(False)
|
|
338
|
+
ax.plot(x, y, '.', color=color, alpha=0.3)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _kdtree_adaptive_bins(x, y, target_count, data_range=None):
|
|
342
|
+
"""
|
|
343
|
+
Create adaptive bins using recursive K-D tree splitting.
|
|
344
|
+
|
|
345
|
+
Recursively splits the data region into rectangular bins until each
|
|
346
|
+
bin contains approximately `target_count` points.
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
x, y : array-like
|
|
351
|
+
Coordinates of data points
|
|
352
|
+
target_count : int
|
|
353
|
+
Target number of points per bin
|
|
354
|
+
data_range : list, optional
|
|
355
|
+
[[xmin, xmax], [ymin, ymax]] range for binning
|
|
356
|
+
|
|
357
|
+
Returns
|
|
358
|
+
-------
|
|
359
|
+
list of tuples
|
|
360
|
+
Each tuple contains (xmin, xmax, ymin, ymax, indices) where indices
|
|
361
|
+
is a boolean array selecting points in that bin.
|
|
362
|
+
"""
|
|
363
|
+
x = np.asarray(x)
|
|
364
|
+
y = np.asarray(y)
|
|
365
|
+
|
|
366
|
+
if data_range is not None:
|
|
367
|
+
xmin, xmax = data_range[0]
|
|
368
|
+
ymin, ymax = data_range[1]
|
|
369
|
+
else:
|
|
370
|
+
xmin, xmax = np.min(x), np.max(x)
|
|
371
|
+
ymin, ymax = np.min(y), np.max(y)
|
|
372
|
+
|
|
373
|
+
bins = []
|
|
374
|
+
|
|
375
|
+
def _split_recursive(idx, xmin, xmax, ymin, ymax, depth=0):
|
|
376
|
+
"""Recursively split region until target count reached."""
|
|
377
|
+
count = np.sum(idx)
|
|
378
|
+
|
|
379
|
+
# Stop splitting if we have few enough points or region is too small
|
|
380
|
+
if count <= target_count * 1.5 or count < 3:
|
|
381
|
+
if count > 0:
|
|
382
|
+
bins.append((xmin, xmax, ymin, ymax, idx.copy()))
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# Alternate splitting direction based on depth, prefer longer dimension
|
|
386
|
+
width = xmax - xmin
|
|
387
|
+
height = ymax - ymin
|
|
388
|
+
|
|
389
|
+
if (depth % 2 == 0 and width >= height) or (depth % 2 == 1 and width > height):
|
|
390
|
+
# Split in x
|
|
391
|
+
x_in_region = x[idx]
|
|
392
|
+
mid = np.median(x_in_region)
|
|
393
|
+
left_idx = idx & (x <= mid)
|
|
394
|
+
right_idx = idx & (x > mid)
|
|
395
|
+
|
|
396
|
+
if np.sum(left_idx) > 0 and np.sum(right_idx) > 0:
|
|
397
|
+
_split_recursive(left_idx, xmin, mid, ymin, ymax, depth + 1)
|
|
398
|
+
_split_recursive(right_idx, mid, xmax, ymin, ymax, depth + 1)
|
|
399
|
+
else:
|
|
400
|
+
bins.append((xmin, xmax, ymin, ymax, idx.copy()))
|
|
401
|
+
else:
|
|
402
|
+
# Split in y
|
|
403
|
+
y_in_region = y[idx]
|
|
404
|
+
mid = np.median(y_in_region)
|
|
405
|
+
bottom_idx = idx & (y <= mid)
|
|
406
|
+
top_idx = idx & (y > mid)
|
|
407
|
+
|
|
408
|
+
if np.sum(bottom_idx) > 0 and np.sum(top_idx) > 0:
|
|
409
|
+
_split_recursive(bottom_idx, xmin, xmax, ymin, mid, depth + 1)
|
|
410
|
+
_split_recursive(top_idx, xmin, xmax, mid, ymax, depth + 1)
|
|
411
|
+
else:
|
|
412
|
+
bins.append((xmin, xmax, ymin, ymax, idx.copy()))
|
|
413
|
+
|
|
414
|
+
# Start with all points in the data range
|
|
415
|
+
initial_idx = (x >= xmin) & (x <= xmax) & (y >= ymin) & (y <= ymax)
|
|
416
|
+
_split_recursive(initial_idx, xmin, xmax, ymin, ymax)
|
|
417
|
+
|
|
418
|
+
return bins
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _powerbin_adaptive_bins(x, y, target_count, data_range=None, verbose=False):
|
|
422
|
+
"""
|
|
423
|
+
Create adaptive bins using PowerBin's centroidal power diagrams.
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
x, y : array-like
|
|
428
|
+
Coordinates of data points
|
|
429
|
+
target_count : int
|
|
430
|
+
Target number of points per bin
|
|
431
|
+
data_range : list, optional
|
|
432
|
+
[[xmin, xmax], [ymin, ymax]] range for binning
|
|
433
|
+
verbose : bool, optional
|
|
434
|
+
Whether to enable PowerBin verbose output
|
|
435
|
+
|
|
436
|
+
Returns
|
|
437
|
+
-------
|
|
438
|
+
bin_numbers : ndarray
|
|
439
|
+
Bin assignment for each point
|
|
440
|
+
centroids : ndarray
|
|
441
|
+
Centroids of each bin (N_bins x 2)
|
|
442
|
+
"""
|
|
443
|
+
if not HAS_POWERBIN:
|
|
444
|
+
raise ImportError("powerbin package is required for this method")
|
|
445
|
+
|
|
446
|
+
from powerbin import PowerBin
|
|
447
|
+
|
|
448
|
+
x = np.asarray(x)
|
|
449
|
+
y = np.asarray(y)
|
|
450
|
+
|
|
451
|
+
if data_range is not None:
|
|
452
|
+
xmin, xmax = data_range[0]
|
|
453
|
+
ymin, ymax = data_range[1]
|
|
454
|
+
mask = (x >= xmin) & (x <= xmax) & (y >= ymin) & (y <= ymax)
|
|
455
|
+
x_use = x[mask]
|
|
456
|
+
y_use = y[mask]
|
|
457
|
+
else:
|
|
458
|
+
x_use = x
|
|
459
|
+
y_use = y
|
|
460
|
+
mask = np.ones(len(x), dtype=bool)
|
|
461
|
+
|
|
462
|
+
n_points = len(x_use)
|
|
463
|
+
if n_points < 4:
|
|
464
|
+
# Not enough points for PowerBin
|
|
465
|
+
raise ValueError("Need at least 4 points for PowerBin")
|
|
466
|
+
|
|
467
|
+
# PowerBin expects xy coordinates and capacity per pixel
|
|
468
|
+
# For count-based binning, each pixel has capacity 1
|
|
469
|
+
xy = np.column_stack([x_use, y_use])
|
|
470
|
+
capacity = np.ones(n_points) # Each point contributes 1 to capacity
|
|
471
|
+
|
|
472
|
+
# Run PowerBin - may fail for scattered (non-gridded) point data
|
|
473
|
+
try:
|
|
474
|
+
pb = PowerBin(
|
|
475
|
+
xy,
|
|
476
|
+
capacity_spec=capacity,
|
|
477
|
+
target_capacity=target_count,
|
|
478
|
+
verbose=1 if verbose else 0,
|
|
479
|
+
maxiter=500,
|
|
480
|
+
)
|
|
481
|
+
except (IndexError, ValueError) as e:
|
|
482
|
+
raise RuntimeError(f"PowerBin failed (may not work with scattered point data): {e}")
|
|
483
|
+
|
|
484
|
+
bin_numbers_subset = pb.bin_num # Note: attribute is bin_num, not bin_number
|
|
485
|
+
centroids = pb.xybin # 2D array of bin centroids
|
|
486
|
+
|
|
487
|
+
# Map back to full array if range was specified
|
|
488
|
+
if data_range is not None:
|
|
489
|
+
bin_numbers = np.full(len(x), -1)
|
|
490
|
+
bin_numbers[mask] = bin_numbers_subset
|
|
491
|
+
else:
|
|
492
|
+
bin_numbers = bin_numbers_subset
|
|
493
|
+
|
|
494
|
+
return bin_numbers, centroids
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def adaptive_binned_map(
|
|
498
|
+
x,
|
|
499
|
+
y,
|
|
500
|
+
value,
|
|
501
|
+
target_count=50,
|
|
502
|
+
target_sn=None,
|
|
503
|
+
err=None,
|
|
504
|
+
statistic='mean',
|
|
505
|
+
method='auto',
|
|
506
|
+
qq=[0.5, 97.5],
|
|
507
|
+
color=None,
|
|
508
|
+
show_colorbar=True,
|
|
509
|
+
show_axis=True,
|
|
510
|
+
show_dots=False,
|
|
511
|
+
show_edges=True,
|
|
512
|
+
ax=None,
|
|
513
|
+
range=None,
|
|
514
|
+
verbose=False,
|
|
515
|
+
**kwargs,
|
|
516
|
+
):
|
|
517
|
+
"""Plot statistical estimators with adaptive (density-based) binning.
|
|
518
|
+
|
|
519
|
+
Bins have variable sizes: sparse regions get larger bins, dense regions
|
|
520
|
+
get finer resolution. Bins target approximately ``target_count`` points
|
|
521
|
+
each, or a specified ``target_sn`` S/N ratio.
|
|
522
|
+
|
|
523
|
+
Parameters
|
|
524
|
+
----------
|
|
525
|
+
x : array_like
|
|
526
|
+
Abscissae of the data points.
|
|
527
|
+
y : array_like
|
|
528
|
+
Ordinates of the data points.
|
|
529
|
+
value : array_like
|
|
530
|
+
Values to aggregate.
|
|
531
|
+
target_count : int, optional
|
|
532
|
+
Target number of points per bin.
|
|
533
|
+
target_sn : float, optional
|
|
534
|
+
Target S/N per bin (alternative to ``target_count``).
|
|
535
|
+
err : array_like, optional
|
|
536
|
+
Per-point value errors; required when ``target_sn`` is set.
|
|
537
|
+
statistic : str or callable, optional
|
|
538
|
+
Aggregation: ``'mean'``, ``'median'``, ``'std'``, ``'count'``, or callable.
|
|
539
|
+
method : str, optional
|
|
540
|
+
Binning algorithm: ``'auto'``, ``'powerbin'``, or ``'kdtree'``.
|
|
541
|
+
qq : list of float, optional
|
|
542
|
+
``[low, high]`` percentile range for color scaling. Default ``[0.5, 97.5]``.
|
|
543
|
+
color : color, optional
|
|
544
|
+
Color for the data-point overlay (requires ``show_dots=True``).
|
|
545
|
+
show_colorbar : bool, optional
|
|
546
|
+
If True, display a colorbar.
|
|
547
|
+
show_axis : bool, optional
|
|
548
|
+
If True, display axis ticks and labels.
|
|
549
|
+
show_dots : bool, optional
|
|
550
|
+
If True, overlay the raw data point positions.
|
|
551
|
+
show_edges : bool, optional
|
|
552
|
+
If True, draw bin boundaries.
|
|
553
|
+
ax : matplotlib.axes.Axes, optional
|
|
554
|
+
Axes to draw on; defaults to the current axes.
|
|
555
|
+
range : list of list, optional
|
|
556
|
+
Data range ``[[xmin, xmax], [ymin, ymax]]``.
|
|
557
|
+
verbose : bool, optional
|
|
558
|
+
If True, enable verbose output from the binning backend.
|
|
559
|
+
**kwargs
|
|
560
|
+
Additional keyword arguments passed to matplotlib.
|
|
561
|
+
"""
|
|
562
|
+
x = np.asarray(x)
|
|
563
|
+
y = np.asarray(y)
|
|
564
|
+
value = np.asarray(value)
|
|
565
|
+
|
|
566
|
+
if ax is None:
|
|
567
|
+
ax = plt.gca()
|
|
568
|
+
|
|
569
|
+
# Filter non-finite data early
|
|
570
|
+
finite_mask = np.isfinite(x) & np.isfinite(y) & np.isfinite(value)
|
|
571
|
+
if err is not None:
|
|
572
|
+
err = np.asarray(err)
|
|
573
|
+
finite_mask &= np.isfinite(err)
|
|
574
|
+
x = x[finite_mask]
|
|
575
|
+
y = y[finite_mask]
|
|
576
|
+
value = value[finite_mask]
|
|
577
|
+
if err is not None:
|
|
578
|
+
err = err[finite_mask]
|
|
579
|
+
|
|
580
|
+
if range is not None:
|
|
581
|
+
xmin, xmax = range[0]
|
|
582
|
+
ymin, ymax = range[1]
|
|
583
|
+
range_mask = (x >= xmin) & (x <= xmax) & (y >= ymin) & (y <= ymax)
|
|
584
|
+
x = x[range_mask]
|
|
585
|
+
y = y[range_mask]
|
|
586
|
+
value = value[range_mask]
|
|
587
|
+
if err is not None:
|
|
588
|
+
err = err[range_mask]
|
|
589
|
+
|
|
590
|
+
if len(x) == 0:
|
|
591
|
+
raise ValueError("No finite data points within the specified range.")
|
|
592
|
+
|
|
593
|
+
# Determine method
|
|
594
|
+
if method == 'auto':
|
|
595
|
+
method = 'powerbin' if HAS_POWERBIN else 'kdtree'
|
|
596
|
+
|
|
597
|
+
# Calculate target_count from target_sn if provided
|
|
598
|
+
if target_sn is not None:
|
|
599
|
+
if err is None:
|
|
600
|
+
raise ValueError("noise parameter required when using target_sn")
|
|
601
|
+
# For S/N targeting, estimate how many points needed
|
|
602
|
+
# S/N ~ sqrt(N) * mean_signal / mean_noise for Poisson
|
|
603
|
+
valid_sn = err > 0
|
|
604
|
+
if np.any(valid_sn):
|
|
605
|
+
mean_sn = np.nanmean(np.abs(value[valid_sn]) / err[valid_sn])
|
|
606
|
+
else:
|
|
607
|
+
mean_sn = np.nan
|
|
608
|
+
if mean_sn > 0:
|
|
609
|
+
target_count = max(3, int((target_sn / mean_sn) ** 2))
|
|
610
|
+
else:
|
|
611
|
+
target_count = 50 # fallback
|
|
612
|
+
|
|
613
|
+
# Get statistic function
|
|
614
|
+
if callable(statistic):
|
|
615
|
+
stat_func = statistic
|
|
616
|
+
elif statistic == 'mean':
|
|
617
|
+
stat_func = np.nanmean
|
|
618
|
+
elif statistic == 'median':
|
|
619
|
+
stat_func = np.nanmedian
|
|
620
|
+
elif statistic == 'std':
|
|
621
|
+
stat_func = np.nanstd
|
|
622
|
+
elif statistic == 'count':
|
|
623
|
+
stat_func = lambda v: len(v)
|
|
624
|
+
else:
|
|
625
|
+
raise ValueError(f"Unknown statistic: {statistic}")
|
|
626
|
+
|
|
627
|
+
# Compute adaptive bins
|
|
628
|
+
powerbin_success = False
|
|
629
|
+
if range is None:
|
|
630
|
+
xmin, xmax = np.min(x), np.max(x)
|
|
631
|
+
ymin, ymax = np.min(y), np.max(y)
|
|
632
|
+
else:
|
|
633
|
+
xmin, xmax = range[0]
|
|
634
|
+
ymin, ymax = range[1]
|
|
635
|
+
|
|
636
|
+
if xmin == xmax or ymin == ymax:
|
|
637
|
+
method = 'kdtree'
|
|
638
|
+
|
|
639
|
+
if method == 'powerbin':
|
|
640
|
+
if not HAS_POWERBIN:
|
|
641
|
+
raise ImportError("powerbin package required. Install with: pip install powerbin")
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
bin_numbers, centroids = _powerbin_adaptive_bins(
|
|
645
|
+
x, y, target_count, range, verbose=verbose
|
|
646
|
+
)
|
|
647
|
+
powerbin_success = True
|
|
648
|
+
except RuntimeError:
|
|
649
|
+
# PowerBin failed (common with scattered point data), fall back to kdtree
|
|
650
|
+
pass
|
|
651
|
+
|
|
652
|
+
if powerbin_success:
|
|
653
|
+
# Compute statistic per bin
|
|
654
|
+
bin_values = np.full(len(centroids), np.nan)
|
|
655
|
+
for b in builtins.range(len(centroids)):
|
|
656
|
+
mask = bin_numbers == b
|
|
657
|
+
if np.any(mask):
|
|
658
|
+
bin_values[b] = stat_func(value[mask])
|
|
659
|
+
|
|
660
|
+
# Create Voronoi diagram for visualization
|
|
661
|
+
if len(centroids) >= 4:
|
|
662
|
+
# Add corner points to bound the Voronoi diagram
|
|
663
|
+
# Pad to ensure all regions are finite
|
|
664
|
+
pad_x = (xmax - xmin) * 0.1
|
|
665
|
+
pad_y = (ymax - ymin) * 0.1
|
|
666
|
+
corners = np.array(
|
|
667
|
+
[
|
|
668
|
+
[xmin - pad_x, ymin - pad_y],
|
|
669
|
+
[xmin - pad_x, ymax + pad_y],
|
|
670
|
+
[xmax + pad_x, ymin - pad_y],
|
|
671
|
+
[xmax + pad_x, ymax + pad_y],
|
|
672
|
+
]
|
|
673
|
+
)
|
|
674
|
+
all_centroids = np.vstack([centroids, corners])
|
|
675
|
+
vor = Voronoi(all_centroids)
|
|
676
|
+
|
|
677
|
+
# Compute vmin/vmax
|
|
678
|
+
finite_vals = bin_values[np.isfinite(bin_values)]
|
|
679
|
+
if len(finite_vals) > 0:
|
|
680
|
+
vmin1, vmax1 = np.nanpercentile(finite_vals, qq)
|
|
681
|
+
else:
|
|
682
|
+
vmin1, vmax1 = 0, 1
|
|
683
|
+
|
|
684
|
+
if 'vmin' not in kwargs:
|
|
685
|
+
kwargs['vmin'] = vmin1
|
|
686
|
+
if 'vmax' not in kwargs:
|
|
687
|
+
kwargs['vmax'] = vmax1
|
|
688
|
+
|
|
689
|
+
# Get colormap
|
|
690
|
+
cmap = kwargs.pop('cmap', 'viridis')
|
|
691
|
+
if isinstance(cmap, str):
|
|
692
|
+
cmap = plt.get_cmap(cmap)
|
|
693
|
+
|
|
694
|
+
norm = plt.Normalize(vmin=kwargs['vmin'], vmax=kwargs['vmax'])
|
|
695
|
+
|
|
696
|
+
# Draw Voronoi cells
|
|
697
|
+
from matplotlib.collections import PolyCollection
|
|
698
|
+
|
|
699
|
+
polygons = []
|
|
700
|
+
colors = []
|
|
701
|
+
for i, val in enumerate(bin_values):
|
|
702
|
+
region_idx = vor.point_region[i]
|
|
703
|
+
region = vor.regions[region_idx]
|
|
704
|
+
if -1 not in region and len(region) > 0:
|
|
705
|
+
polygon = [vor.vertices[j] for j in region]
|
|
706
|
+
# Clip to data range
|
|
707
|
+
polygon = np.array(polygon)
|
|
708
|
+
polygon[:, 0] = np.clip(polygon[:, 0], xmin, xmax)
|
|
709
|
+
polygon[:, 1] = np.clip(polygon[:, 1], ymin, ymax)
|
|
710
|
+
polygons.append(polygon)
|
|
711
|
+
colors.append(cmap(norm(val)) if np.isfinite(val) else (0.8, 0.8, 0.8, 1))
|
|
712
|
+
|
|
713
|
+
pc = PolyCollection(
|
|
714
|
+
polygons,
|
|
715
|
+
facecolors=colors,
|
|
716
|
+
edgecolors=kwargs.pop('edgecolors', 'black') if show_edges else 'none',
|
|
717
|
+
linewidths=kwargs.pop('linewidths', 0.5) if show_edges else 0,
|
|
718
|
+
)
|
|
719
|
+
ax.add_collection(pc)
|
|
720
|
+
ax.set_xlim(xmin, xmax)
|
|
721
|
+
ax.set_ylim(ymin, ymax)
|
|
722
|
+
|
|
723
|
+
if show_colorbar:
|
|
724
|
+
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
|
|
725
|
+
sm.set_array([])
|
|
726
|
+
# ax.get_figure().colorbar(sm, ax=ax)
|
|
727
|
+
colorbar(sm, ax=ax)
|
|
728
|
+
|
|
729
|
+
else:
|
|
730
|
+
# Too few bins, will fall back to kdtree
|
|
731
|
+
powerbin_success = False
|
|
732
|
+
|
|
733
|
+
if method == 'kdtree' or (method == 'powerbin' and not powerbin_success):
|
|
734
|
+
bins_list = _kdtree_adaptive_bins(x, y, target_count, range)
|
|
735
|
+
|
|
736
|
+
# Compute statistic per bin
|
|
737
|
+
bin_values = []
|
|
738
|
+
for xmin_b, xmax_b, ymin_b, ymax_b, idx in bins_list:
|
|
739
|
+
bin_values.append(stat_func(value[idx]))
|
|
740
|
+
bin_values = np.array(bin_values)
|
|
741
|
+
|
|
742
|
+
# Compute vmin/vmax
|
|
743
|
+
finite_vals = bin_values[np.isfinite(bin_values)]
|
|
744
|
+
if len(finite_vals) > 0:
|
|
745
|
+
vmin1, vmax1 = np.nanpercentile(finite_vals, qq)
|
|
746
|
+
else:
|
|
747
|
+
vmin1, vmax1 = 0, 1
|
|
748
|
+
|
|
749
|
+
if 'vmin' not in kwargs:
|
|
750
|
+
kwargs['vmin'] = vmin1
|
|
751
|
+
if 'vmax' not in kwargs:
|
|
752
|
+
kwargs['vmax'] = vmax1
|
|
753
|
+
|
|
754
|
+
# Get colormap
|
|
755
|
+
cmap = kwargs.pop('cmap', 'viridis')
|
|
756
|
+
if isinstance(cmap, str):
|
|
757
|
+
cmap = plt.get_cmap(cmap)
|
|
758
|
+
|
|
759
|
+
norm = plt.Normalize(vmin=kwargs['vmin'], vmax=kwargs['vmax'])
|
|
760
|
+
|
|
761
|
+
# Draw rectangles
|
|
762
|
+
from matplotlib.patches import Rectangle
|
|
763
|
+
from matplotlib.collections import PatchCollection
|
|
764
|
+
|
|
765
|
+
patches = []
|
|
766
|
+
colors = []
|
|
767
|
+
for i, (xmin_b, xmax_b, ymin_b, ymax_b, idx) in enumerate(bins_list):
|
|
768
|
+
rect = Rectangle(
|
|
769
|
+
(xmin_b, ymin_b),
|
|
770
|
+
xmax_b - xmin_b,
|
|
771
|
+
ymax_b - ymin_b,
|
|
772
|
+
)
|
|
773
|
+
patches.append(rect)
|
|
774
|
+
val = bin_values[i]
|
|
775
|
+
colors.append(cmap(norm(val)) if np.isfinite(val) else (0.8, 0.8, 0.8, 1))
|
|
776
|
+
|
|
777
|
+
pc = PatchCollection(
|
|
778
|
+
patches,
|
|
779
|
+
facecolors=colors,
|
|
780
|
+
edgecolors='black' if show_edges else 'none',
|
|
781
|
+
linewidths=0.5 if show_edges else 0,
|
|
782
|
+
)
|
|
783
|
+
ax.add_collection(pc)
|
|
784
|
+
|
|
785
|
+
# Set axis limits
|
|
786
|
+
ax.set_xlim(xmin, xmax)
|
|
787
|
+
ax.set_ylim(ymin, ymax)
|
|
788
|
+
|
|
789
|
+
if show_colorbar:
|
|
790
|
+
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
|
|
791
|
+
sm.set_array([])
|
|
792
|
+
# ax.get_figure().colorbar(sm, ax=ax)
|
|
793
|
+
colorbar(sm, ax=ax)
|
|
794
|
+
|
|
795
|
+
if not show_axis:
|
|
796
|
+
ax.set_axis_off()
|
|
797
|
+
else:
|
|
798
|
+
ax.set_axis_on()
|
|
799
|
+
|
|
800
|
+
if show_dots:
|
|
801
|
+
ax.set_autoscale_on(False)
|
|
802
|
+
ax.plot(x, y, '.', color=color, alpha=0.3)
|
|
803
|
+
|
|
804
|
+
ax.set_aspect(kwargs.get('aspect', 'auto'))
|
|
805
|
+
# if 'aspect' not in kwargs:
|
|
806
|
+
# ax.set_aspect('auto')
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def plot_cutout(
|
|
810
|
+
cutout,
|
|
811
|
+
planes=['image', 'template', 'diff', 'mask'],
|
|
812
|
+
fig=None,
|
|
813
|
+
axs=None,
|
|
814
|
+
mark_x=None,
|
|
815
|
+
mark_y=None,
|
|
816
|
+
mark_r=5.0,
|
|
817
|
+
mark_r2=None,
|
|
818
|
+
mark_r3=None,
|
|
819
|
+
mark_color='red',
|
|
820
|
+
mark_lw=2,
|
|
821
|
+
mark_ra=None,
|
|
822
|
+
mark_dec=None,
|
|
823
|
+
r0=None,
|
|
824
|
+
show_title=True,
|
|
825
|
+
title=None,
|
|
826
|
+
additional_title=None,
|
|
827
|
+
**kwargs,
|
|
828
|
+
):
|
|
829
|
+
"""Display image planes from a cutout structure in a single row.
|
|
830
|
+
|
|
831
|
+
Parameters
|
|
832
|
+
----------
|
|
833
|
+
cutout : dict
|
|
834
|
+
Cutout structure as returned by :func:`stdpipe.cutouts.get_cutout`.
|
|
835
|
+
planes : list of str, optional
|
|
836
|
+
Names of cutout planes to show (in order).
|
|
837
|
+
fig : matplotlib.figure.Figure, optional
|
|
838
|
+
Figure to draw into; a new figure is created if not provided.
|
|
839
|
+
axs : list of matplotlib.axes.Axes, optional
|
|
840
|
+
Axes to draw into; must be the same length as ``planes``.
|
|
841
|
+
mark_x : float, optional
|
|
842
|
+
X coordinate (in cutout pixels) of the circular overlay mark.
|
|
843
|
+
mark_y : float, optional
|
|
844
|
+
Y coordinate (in cutout pixels) of the circular overlay mark.
|
|
845
|
+
mark_r : float, optional
|
|
846
|
+
Radius of the overlay mark in pixels.
|
|
847
|
+
mark_r2 : float, optional
|
|
848
|
+
Radius of a secondary dashed overlay circle.
|
|
849
|
+
mark_r3 : float, optional
|
|
850
|
+
Radius of a tertiary dashed overlay circle.
|
|
851
|
+
mark_color : color, optional
|
|
852
|
+
Color of the overlay mark.
|
|
853
|
+
mark_lw : float, optional
|
|
854
|
+
Line width of the overlay mark.
|
|
855
|
+
mark_ra : float, optional
|
|
856
|
+
RA of the overlay mark; overrides ``mark_x`` / ``mark_y``.
|
|
857
|
+
mark_dec : float, optional
|
|
858
|
+
Dec of the overlay mark; overrides ``mark_x`` / ``mark_y``.
|
|
859
|
+
r0 : float, optional
|
|
860
|
+
Gaussian smoothing sigma applied to ``image``, ``template``, and ``diff`` planes.
|
|
861
|
+
show_title : bool, optional
|
|
862
|
+
If True (default), display a title above the cutout row.
|
|
863
|
+
title : str, optional
|
|
864
|
+
Title text. Auto-generated from cutout metadata if not provided.
|
|
865
|
+
additional_title : str, optional
|
|
866
|
+
Text appended to the auto-generated title.
|
|
867
|
+
**kwargs
|
|
868
|
+
Additional keyword arguments passed to :func:`imshow` for each plane.
|
|
869
|
+
"""
|
|
870
|
+
|
|
871
|
+
curplot = 1
|
|
872
|
+
|
|
873
|
+
nplots = len([_ for _ in planes if _ in cutout])
|
|
874
|
+
|
|
875
|
+
if fig is None:
|
|
876
|
+
fig = plt.figure(figsize=[nplots * 4, 4 + 1.0], dpi=75, tight_layout=True)
|
|
877
|
+
|
|
878
|
+
if axs is not None:
|
|
879
|
+
if not len(axs) == len(planes):
|
|
880
|
+
raise ValueError('Number of axes must be same as number of cutouts')
|
|
881
|
+
|
|
882
|
+
for ii, name in enumerate(planes):
|
|
883
|
+
if name in cutout and cutout[name] is not None:
|
|
884
|
+
if axs is not None:
|
|
885
|
+
ax = axs[ii]
|
|
886
|
+
else:
|
|
887
|
+
ax = fig.add_subplot(1, nplots, curplot)
|
|
888
|
+
curplot += 1
|
|
889
|
+
|
|
890
|
+
params = {
|
|
891
|
+
'stretch': 'asinh' if name in ['image', 'template', 'convolved'] else 'linear',
|
|
892
|
+
'r0': r0 if name in ['image', 'template', 'diff'] else None,
|
|
893
|
+
# 'qq': [0.5, 100] if name in ['image', 'template', 'convolved'] else [0.5, 99.5],
|
|
894
|
+
'cmap': 'Blues_r',
|
|
895
|
+
'show_colorbar': False,
|
|
896
|
+
'show_axis': False,
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
params.update(kwargs)
|
|
900
|
+
|
|
901
|
+
imshow(cutout[name], ax=ax, **params)
|
|
902
|
+
ax.set_title(name.upper())
|
|
903
|
+
|
|
904
|
+
if mark_ra is not None and mark_dec is not None and cutout.get('wcs'):
|
|
905
|
+
mark_x, mark_y = cutout['wcs'].all_world2pix(mark_ra, mark_dec, 0)
|
|
906
|
+
|
|
907
|
+
if mark_x is not None and mark_y is not None:
|
|
908
|
+
ax.add_artist(
|
|
909
|
+
Circle(
|
|
910
|
+
(mark_x, mark_y),
|
|
911
|
+
mark_r,
|
|
912
|
+
edgecolor=mark_color,
|
|
913
|
+
facecolor='none',
|
|
914
|
+
ls='-',
|
|
915
|
+
lw=mark_lw,
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
for _ in [mark_r2, mark_r3]:
|
|
920
|
+
if _ is not None:
|
|
921
|
+
ax.add_artist(
|
|
922
|
+
Circle(
|
|
923
|
+
(mark_x, mark_y),
|
|
924
|
+
_,
|
|
925
|
+
edgecolor=mark_color,
|
|
926
|
+
facecolor='none',
|
|
927
|
+
ls='--',
|
|
928
|
+
lw=mark_lw / 2,
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
if curplot > nplots:
|
|
933
|
+
break
|
|
934
|
+
|
|
935
|
+
if show_title:
|
|
936
|
+
if title is None:
|
|
937
|
+
title = cutout['meta'].get('name', 'unnamed')
|
|
938
|
+
if 'time' in cutout['meta']:
|
|
939
|
+
title += ' at %s' % cutout['meta']['time'].to_value('iso')
|
|
940
|
+
|
|
941
|
+
if 'mag_filter_name' in cutout['meta']:
|
|
942
|
+
title += ' : ' + cutout['meta']['mag_filter_name']
|
|
943
|
+
if (
|
|
944
|
+
'mag_color_name' in cutout['meta']
|
|
945
|
+
and 'mag_color_term' in cutout['meta']
|
|
946
|
+
and cutout['meta']['mag_color_term'] is not None
|
|
947
|
+
):
|
|
948
|
+
title += ' ' + photometry.format_color_term(
|
|
949
|
+
cutout['meta']['mag_color_term'],
|
|
950
|
+
color_name=cutout['meta']['mag_color_name'],
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
if 'mag_limit' in cutout['meta']:
|
|
954
|
+
title += ' : limit %.2f' % cutout['meta']['mag_limit']
|
|
955
|
+
|
|
956
|
+
if 'mag_calib' in cutout['meta']:
|
|
957
|
+
title += ' : mag = %.2f $\\pm$ %.2f' % (
|
|
958
|
+
cutout['meta'].get('mag_calib', np.nan),
|
|
959
|
+
cutout['meta'].get('mag_calib_err', cutout['meta'].get('magerr', np.nan)),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
if additional_title:
|
|
963
|
+
title += ' : ' + additional_title
|
|
964
|
+
|
|
965
|
+
fig.suptitle(title)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def plot_photometric_match(m, ax=None, mode='mag', show_masked=True, show_final=True, **kwargs):
|
|
969
|
+
"""Plot photometric match diagnostics.
|
|
970
|
+
|
|
971
|
+
Displays various representations of the results returned by
|
|
972
|
+
:func:`stdpipe.photometry.match` or :func:`stdpipe.pipeline.calibrate_photometry`.
|
|
973
|
+
|
|
974
|
+
Available modes:
|
|
975
|
+
|
|
976
|
+
- ``'mag'`` — photometric residuals vs catalogue magnitude
|
|
977
|
+
- ``'normed'`` — residuals divided by errors vs catalogue magnitude
|
|
978
|
+
- ``'color'`` — residuals vs catalogue color
|
|
979
|
+
- ``'zero'`` — spatial map of the empirical zero point
|
|
980
|
+
- ``'model'`` — spatial map of the zero-point model
|
|
981
|
+
- ``'residuals'`` — spatial map of zero-point fitting residuals
|
|
982
|
+
- ``'dist'`` — spatial map of angular separation (arcsec)
|
|
983
|
+
|
|
984
|
+
Parameters
|
|
985
|
+
----------
|
|
986
|
+
m : dict
|
|
987
|
+
Photometric match results dictionary.
|
|
988
|
+
ax : matplotlib.axes.Axes, optional
|
|
989
|
+
Axes to draw on; defaults to the current axes.
|
|
990
|
+
mode : str, optional
|
|
991
|
+
Plotting mode (see above).
|
|
992
|
+
show_masked : bool, optional
|
|
993
|
+
If True, include masked objects in the plot.
|
|
994
|
+
show_final : bool, optional
|
|
995
|
+
If True, highlight objects used in the final fit.
|
|
996
|
+
**kwargs
|
|
997
|
+
Additional keyword arguments passed to :func:`binned_map` where applicable.
|
|
998
|
+
"""
|
|
999
|
+
if ax is None:
|
|
1000
|
+
ax = plt.gca()
|
|
1001
|
+
|
|
1002
|
+
# Textual representation of the photometric model
|
|
1003
|
+
model_str = 'Instr = %s' % m.get('cat_col_mag', 'Cat')
|
|
1004
|
+
|
|
1005
|
+
if (
|
|
1006
|
+
'cat_col_mag1' in m.keys()
|
|
1007
|
+
and 'cat_col_mag2' in m.keys()
|
|
1008
|
+
and 'color_term' in m.keys()
|
|
1009
|
+
and m['color_term'] is not None
|
|
1010
|
+
):
|
|
1011
|
+
model_str += ' ' + photometry.format_color_term(
|
|
1012
|
+
m['color_term'],
|
|
1013
|
+
color_name='%s - %s'
|
|
1014
|
+
% (
|
|
1015
|
+
m['cat_col_mag1'],
|
|
1016
|
+
m['cat_col_mag2'],
|
|
1017
|
+
),
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
model_str += ' + ZP'
|
|
1021
|
+
|
|
1022
|
+
if mode == 'mag':
|
|
1023
|
+
ax.errorbar(
|
|
1024
|
+
m['cmag'][m['idx0']],
|
|
1025
|
+
(m['zero_model'] - m['zero'])[m['idx0']],
|
|
1026
|
+
m['zero_err'][m['idx0']],
|
|
1027
|
+
fmt='.',
|
|
1028
|
+
alpha=0.3,
|
|
1029
|
+
)
|
|
1030
|
+
if show_final:
|
|
1031
|
+
ax.plot(
|
|
1032
|
+
m['cmag'][m['idx']],
|
|
1033
|
+
(m['zero_model'] - m['zero'])[m['idx']],
|
|
1034
|
+
'.',
|
|
1035
|
+
alpha=1.0,
|
|
1036
|
+
color='red',
|
|
1037
|
+
label='Final fit',
|
|
1038
|
+
)
|
|
1039
|
+
if show_masked:
|
|
1040
|
+
ax.plot(
|
|
1041
|
+
m['cmag'][~m['idx0']],
|
|
1042
|
+
(m['zero_model'] - m['zero'])[~m['idx0']],
|
|
1043
|
+
'x',
|
|
1044
|
+
alpha=1.0,
|
|
1045
|
+
color='orange',
|
|
1046
|
+
label='Masked',
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
ax.axhline(0, ls='--', color='black', alpha=0.3)
|
|
1050
|
+
ax.legend()
|
|
1051
|
+
ax.grid(alpha=0.2)
|
|
1052
|
+
|
|
1053
|
+
ax.set_xlabel(
|
|
1054
|
+
'Catalogue %s magnitude' % (m['cat_col_mag'] if 'cat_col_mag' in m.keys() else '')
|
|
1055
|
+
)
|
|
1056
|
+
ax.set_ylabel('Instrumental - Model')
|
|
1057
|
+
|
|
1058
|
+
ax.set_title(
|
|
1059
|
+
'%d of %d unmasked stars used in final fit' % (np.sum(m['idx']), np.sum(m['idx0']))
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
ax.text(0.02, 0.05, model_str, transform=ax.transAxes)
|
|
1063
|
+
|
|
1064
|
+
elif mode == 'normed':
|
|
1065
|
+
ax.plot(
|
|
1066
|
+
m['cmag'][m['idx0']],
|
|
1067
|
+
((m['zero_model'] - m['zero']) / m['zero_err'])[m['idx0']],
|
|
1068
|
+
'.',
|
|
1069
|
+
alpha=0.3,
|
|
1070
|
+
)
|
|
1071
|
+
if show_final:
|
|
1072
|
+
ax.plot(
|
|
1073
|
+
m['cmag'][m['idx']],
|
|
1074
|
+
((m['zero_model'] - m['zero']) / m['zero_err'])[m['idx']],
|
|
1075
|
+
'.',
|
|
1076
|
+
alpha=1.0,
|
|
1077
|
+
color='red',
|
|
1078
|
+
label='Final fit',
|
|
1079
|
+
)
|
|
1080
|
+
if show_masked:
|
|
1081
|
+
ax.plot(
|
|
1082
|
+
m['cmag'][~m['idx0']],
|
|
1083
|
+
((m['zero_model'] - m['zero']) / m['zero_err'])[~m['idx0']],
|
|
1084
|
+
'x',
|
|
1085
|
+
alpha=1.0,
|
|
1086
|
+
color='orange',
|
|
1087
|
+
label='Masked',
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
ax.axhline(0, ls='--', color='black', alpha=0.3)
|
|
1091
|
+
ax.axhline(-3, ls=':', color='black', alpha=0.3)
|
|
1092
|
+
ax.axhline(3, ls=':', color='black', alpha=0.3)
|
|
1093
|
+
ax.legend()
|
|
1094
|
+
ax.grid(alpha=0.2)
|
|
1095
|
+
|
|
1096
|
+
ax.set_xlabel(
|
|
1097
|
+
'Catalogue %s magnitude' % (m['cat_col_mag'] if 'cat_col_mag' in m.keys() else '')
|
|
1098
|
+
)
|
|
1099
|
+
ax.set_ylabel('(Instrumental - Model) / Error')
|
|
1100
|
+
|
|
1101
|
+
ax.set_title(
|
|
1102
|
+
'%d of %d unmasked stars used in final fit' % (np.sum(m['idx']), np.sum(m['idx0']))
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
ax.text(0.02, 0.05, model_str, transform=ax.transAxes)
|
|
1106
|
+
|
|
1107
|
+
elif mode == 'color':
|
|
1108
|
+
ax.errorbar(
|
|
1109
|
+
m['color'][m['idx0']],
|
|
1110
|
+
(m['zero_model'] - m['zero'])[m['idx0']],
|
|
1111
|
+
m['zero_err'][m['idx0']],
|
|
1112
|
+
fmt='.',
|
|
1113
|
+
alpha=0.3,
|
|
1114
|
+
)
|
|
1115
|
+
if show_final:
|
|
1116
|
+
ax.plot(
|
|
1117
|
+
m['color'][m['idx']],
|
|
1118
|
+
(m['zero_model'] - m['zero'])[m['idx']],
|
|
1119
|
+
'.',
|
|
1120
|
+
alpha=1.0,
|
|
1121
|
+
color='red',
|
|
1122
|
+
label='Final fit',
|
|
1123
|
+
)
|
|
1124
|
+
if show_masked:
|
|
1125
|
+
ax.plot(
|
|
1126
|
+
m['color'][~m['idx0']],
|
|
1127
|
+
(m['zero_model'] - m['zero'])[~m['idx0']],
|
|
1128
|
+
'x',
|
|
1129
|
+
alpha=1.0,
|
|
1130
|
+
color='orange',
|
|
1131
|
+
label='Masked',
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
ax.axhline(0, ls='--', color='black', alpha=0.3)
|
|
1135
|
+
ax.legend()
|
|
1136
|
+
ax.grid(alpha=0.2)
|
|
1137
|
+
|
|
1138
|
+
ax.set_xlabel(
|
|
1139
|
+
'Catalogue %s color'
|
|
1140
|
+
% (m['cat_col_mag1'] + '-' + m['cat_col_mag2'] if 'cat_col_mag1' in m.keys() else '')
|
|
1141
|
+
)
|
|
1142
|
+
ax.set_ylabel('Instrumental - Model')
|
|
1143
|
+
|
|
1144
|
+
ax.set_title('color term = ' + photometry.format_color_term(m['color_term']))
|
|
1145
|
+
|
|
1146
|
+
ax.text(0.02, 0.05, model_str, transform=ax.transAxes)
|
|
1147
|
+
|
|
1148
|
+
elif mode == 'zero':
|
|
1149
|
+
if show_final:
|
|
1150
|
+
binned_map(
|
|
1151
|
+
m['ox'][m['idx']],
|
|
1152
|
+
m['oy'][m['idx']],
|
|
1153
|
+
m['zero'][m['idx']],
|
|
1154
|
+
ax=ax,
|
|
1155
|
+
**kwargs,
|
|
1156
|
+
)
|
|
1157
|
+
else:
|
|
1158
|
+
binned_map(
|
|
1159
|
+
m['ox'][m['idx0']],
|
|
1160
|
+
m['oy'][m['idx0']],
|
|
1161
|
+
m['zero'][m['idx0']],
|
|
1162
|
+
ax=ax,
|
|
1163
|
+
**kwargs,
|
|
1164
|
+
)
|
|
1165
|
+
ax.set_title('Zero point')
|
|
1166
|
+
|
|
1167
|
+
elif mode == 'model':
|
|
1168
|
+
binned_map(
|
|
1169
|
+
m['ox'][m['idx0']],
|
|
1170
|
+
m['oy'][m['idx0']],
|
|
1171
|
+
m['zero_model'][m['idx0']],
|
|
1172
|
+
ax=ax,
|
|
1173
|
+
**kwargs,
|
|
1174
|
+
)
|
|
1175
|
+
ax.set_title('Model')
|
|
1176
|
+
|
|
1177
|
+
elif mode == 'residuals':
|
|
1178
|
+
binned_map(
|
|
1179
|
+
m['ox'][m['idx0']],
|
|
1180
|
+
m['oy'][m['idx0']],
|
|
1181
|
+
(m['zero_model'] - m['zero'])[m['idx0']],
|
|
1182
|
+
ax=ax,
|
|
1183
|
+
**kwargs,
|
|
1184
|
+
)
|
|
1185
|
+
ax.set_title('Instrumental - model')
|
|
1186
|
+
|
|
1187
|
+
elif mode == 'dist':
|
|
1188
|
+
binned_map(
|
|
1189
|
+
m['ox'][m['idx']],
|
|
1190
|
+
m['oy'][m['idx']],
|
|
1191
|
+
m['dist'][m['idx']] * 3600,
|
|
1192
|
+
ax=ax,
|
|
1193
|
+
**kwargs,
|
|
1194
|
+
)
|
|
1195
|
+
ax.set_title(
|
|
1196
|
+
'%d stars: mean displacement %.1f arcsec, median %.1f arcsec'
|
|
1197
|
+
% (
|
|
1198
|
+
np.sum(m['idx']),
|
|
1199
|
+
np.mean(m['dist'][m['idx']] * 3600),
|
|
1200
|
+
np.median(m['dist'][m['idx']] * 3600),
|
|
1201
|
+
)
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
return ax
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def plot_detection_limit(
|
|
1208
|
+
obj,
|
|
1209
|
+
sn=5,
|
|
1210
|
+
mag_name=None,
|
|
1211
|
+
obj_col_mag='mag_calib',
|
|
1212
|
+
obj_col_mag_err='magerr',
|
|
1213
|
+
show_local=True,
|
|
1214
|
+
ax=None,
|
|
1215
|
+
):
|
|
1216
|
+
"""Plot S/N vs magnitude with detection limit model.
|
|
1217
|
+
|
|
1218
|
+
Parameters
|
|
1219
|
+
----------
|
|
1220
|
+
obj : astropy.table.Table
|
|
1221
|
+
Table with calibrated object detections.
|
|
1222
|
+
sn : float, optional
|
|
1223
|
+
S/N threshold defining the detection limit.
|
|
1224
|
+
mag_name : str, optional
|
|
1225
|
+
Axis label for the magnitude.
|
|
1226
|
+
obj_col_mag : str, optional
|
|
1227
|
+
Column name for calibrated magnitude.
|
|
1228
|
+
obj_col_mag_err : str, optional
|
|
1229
|
+
Column name for magnitude error.
|
|
1230
|
+
show_local : bool, optional
|
|
1231
|
+
If True, overlay local per-object detection limits from ``bg_fluxerr``.
|
|
1232
|
+
ax : matplotlib.axes.Axes, optional
|
|
1233
|
+
Axes to draw on; defaults to the current axes.
|
|
1234
|
+
"""
|
|
1235
|
+
if ax is None:
|
|
1236
|
+
ax = plt.gca()
|
|
1237
|
+
|
|
1238
|
+
mag = obj[obj_col_mag]
|
|
1239
|
+
mag_sn = 1 / obj[obj_col_mag_err]
|
|
1240
|
+
|
|
1241
|
+
ax.plot(mag, mag_sn, '.', alpha=(0.2 if len(mag_sn) > 1000 else 0.4), label='Objects')
|
|
1242
|
+
|
|
1243
|
+
ax.axhline(sn, color='black', ls='--', label=f"S/N={sn}")
|
|
1244
|
+
|
|
1245
|
+
mag0, sn_model = photometry.get_detection_limit_sn(mag, mag_sn, sn=sn, get_model=True)
|
|
1246
|
+
if mag0 is not None:
|
|
1247
|
+
x0 = np.linspace(np.nanmin(mag) - 1, max(np.nanmax(mag), mag0 + 1))
|
|
1248
|
+
ax.plot(x0, sn_model(x0), '-', color='red', label='Model')
|
|
1249
|
+
ax.axvline(mag0, ls=':', color='red', alpha=0.3, label=f"S/N={sn}")
|
|
1250
|
+
ax.set_title(f"S/N={sn} limit is {mag0:.2f}")
|
|
1251
|
+
else:
|
|
1252
|
+
ax.set_title("Cannot estimate detection limit")
|
|
1253
|
+
|
|
1254
|
+
# Local bg rms detection limit
|
|
1255
|
+
if show_local and 'bg_fluxerr' in obj.colnames:
|
|
1256
|
+
fluxerr = obj['bg_fluxerr']
|
|
1257
|
+
zero = obj['mag_calib'] - obj['mag']
|
|
1258
|
+
maglim = -2.5 * np.log10(sn * fluxerr) + zero
|
|
1259
|
+
|
|
1260
|
+
ax.plot(
|
|
1261
|
+
maglim,
|
|
1262
|
+
np.ones_like(maglim) * sn,
|
|
1263
|
+
'o',
|
|
1264
|
+
color='orange',
|
|
1265
|
+
label='Local RMS',
|
|
1266
|
+
alpha=0.2,
|
|
1267
|
+
)
|
|
1268
|
+
# ax.violinplot(maglim, [sn], vert=False, showmedians=True, color='orange')
|
|
1269
|
+
|
|
1270
|
+
ax.set_yscale('log')
|
|
1271
|
+
|
|
1272
|
+
ax.grid(alpha=0.2)
|
|
1273
|
+
ax.legend()
|
|
1274
|
+
ax.set_xlabel(mag_name)
|
|
1275
|
+
ax.set_ylabel('Signal / Noise')
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def plot_mag_histogram(
|
|
1279
|
+
obj,
|
|
1280
|
+
cat=None,
|
|
1281
|
+
cat_col_mag=None,
|
|
1282
|
+
sn=None,
|
|
1283
|
+
obj_col_mag='mag_calib',
|
|
1284
|
+
obj_col_mag_err='magerr',
|
|
1285
|
+
accept_flags=0,
|
|
1286
|
+
ax=None,
|
|
1287
|
+
):
|
|
1288
|
+
"""Plot a histogram of calibrated magnitudes.
|
|
1289
|
+
|
|
1290
|
+
Parameters
|
|
1291
|
+
----------
|
|
1292
|
+
obj : astropy.table.Table
|
|
1293
|
+
Table with calibrated object detections.
|
|
1294
|
+
cat : astropy.table.Table, optional
|
|
1295
|
+
Reference catalogue; its magnitudes are overlaid as a separate histogram.
|
|
1296
|
+
cat_col_mag : str, optional
|
|
1297
|
+
Column name for catalogue magnitudes.
|
|
1298
|
+
sn : float, optional
|
|
1299
|
+
If set, overplot the S/N detection limit as a vertical line.
|
|
1300
|
+
obj_col_mag : str, optional
|
|
1301
|
+
Column name for object calibrated magnitude.
|
|
1302
|
+
obj_col_mag_err : str, optional
|
|
1303
|
+
Column name for object magnitude error.
|
|
1304
|
+
accept_flags : int, optional
|
|
1305
|
+
Bitmask of acceptable object flags (objects matching this mask are shown
|
|
1306
|
+
as unflagged).
|
|
1307
|
+
ax : matplotlib.axes.Axes, optional
|
|
1308
|
+
Axes to draw on; defaults to the current axes.
|
|
1309
|
+
"""
|
|
1310
|
+
if ax is None:
|
|
1311
|
+
ax = plt.gca()
|
|
1312
|
+
|
|
1313
|
+
mag = obj[obj_col_mag]
|
|
1314
|
+
mag_sn = 1 / obj[obj_col_mag_err]
|
|
1315
|
+
|
|
1316
|
+
idx = (obj['flags'] & ~accept_flags) == 0
|
|
1317
|
+
|
|
1318
|
+
vmin = np.nanmin(mag)
|
|
1319
|
+
vmax = np.nanmax(mag)
|
|
1320
|
+
|
|
1321
|
+
if cat and cat_col_mag in cat.colnames:
|
|
1322
|
+
cmag = cat[cat_col_mag]
|
|
1323
|
+
cmag = cmag[(cmag > -10) & (cmag < 30)] # To exclude common filler values
|
|
1324
|
+
vmin = min(vmin, np.nanmin(cmag))
|
|
1325
|
+
vmax = max(vmax, np.nanmax(cmag))
|
|
1326
|
+
else:
|
|
1327
|
+
cmag = None
|
|
1328
|
+
|
|
1329
|
+
vmin = np.floor(vmin)
|
|
1330
|
+
vmax = np.ceil(vmax)
|
|
1331
|
+
|
|
1332
|
+
ax.hist(mag, bins=np.linspace(vmin, vmax, 50), alpha=0.4, color='C0', label="Objects")
|
|
1333
|
+
ax.hist(mag, bins=np.linspace(vmin, vmax, 50), alpha=0.8, histtype='step', color='C0')
|
|
1334
|
+
|
|
1335
|
+
ax.hist(
|
|
1336
|
+
mag[idx],
|
|
1337
|
+
bins=np.linspace(vmin, vmax, 50),
|
|
1338
|
+
alpha=0.2,
|
|
1339
|
+
color='C2',
|
|
1340
|
+
label="Unflagged objects",
|
|
1341
|
+
)
|
|
1342
|
+
ax.hist(
|
|
1343
|
+
mag[idx],
|
|
1344
|
+
bins=np.linspace(vmin, vmax, 50),
|
|
1345
|
+
alpha=0.6,
|
|
1346
|
+
histtype='step',
|
|
1347
|
+
color='C2',
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
if cmag is not None:
|
|
1351
|
+
ax.hist(
|
|
1352
|
+
cmag,
|
|
1353
|
+
bins=np.linspace(vmin, vmax, 50),
|
|
1354
|
+
alpha=0.3,
|
|
1355
|
+
color='C1',
|
|
1356
|
+
label="Catalogue",
|
|
1357
|
+
)
|
|
1358
|
+
ax.hist(
|
|
1359
|
+
cmag,
|
|
1360
|
+
bins=np.linspace(vmin, vmax, 50),
|
|
1361
|
+
alpha=0.8,
|
|
1362
|
+
histtype='step',
|
|
1363
|
+
color='C1',
|
|
1364
|
+
)
|
|
1365
|
+
ax.set_xlabel(cat_col_mag)
|
|
1366
|
+
else:
|
|
1367
|
+
ax.set_xlabel('Magnitude')
|
|
1368
|
+
|
|
1369
|
+
if sn:
|
|
1370
|
+
mag0 = photometry.get_detection_limit_sn(mag, mag_sn, sn=sn)
|
|
1371
|
+
if mag0 is not None:
|
|
1372
|
+
ax.axvline(mag0, ls=':', color='red', alpha=0.3, label=f"S/N={sn}")
|
|
1373
|
+
|
|
1374
|
+
ax.grid(alpha=0.2)
|
|
1375
|
+
ax.legend()
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
from contextlib import contextmanager
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
@contextmanager
|
|
1382
|
+
def figure_saver(filename=None, show=False, tight_layout=True, **kwargs):
|
|
1383
|
+
"""Context manager that creates a Figure, saves it, and optionally displays it.
|
|
1384
|
+
|
|
1385
|
+
Example::
|
|
1386
|
+
|
|
1387
|
+
with figure_saver('/tmp/figure.png', show=True, figsize=(10, 6)) as fig:
|
|
1388
|
+
ax = fig.add_subplot(111)
|
|
1389
|
+
ax.plot(x, y, '.-')
|
|
1390
|
+
|
|
1391
|
+
Parameters
|
|
1392
|
+
----------
|
|
1393
|
+
filename : str, optional
|
|
1394
|
+
Output file path. Any format supported by Matplotlib is accepted.
|
|
1395
|
+
show : bool, optional
|
|
1396
|
+
If True, display the figure in a Jupyter notebook after saving.
|
|
1397
|
+
tight_layout : bool, optional
|
|
1398
|
+
If True, call ``fig.tight_layout()`` before saving.
|
|
1399
|
+
**kwargs
|
|
1400
|
+
Additional keyword arguments passed to :class:`matplotlib.pyplot.Figure`.
|
|
1401
|
+
"""
|
|
1402
|
+
|
|
1403
|
+
fig = plt.Figure(**kwargs)
|
|
1404
|
+
|
|
1405
|
+
try:
|
|
1406
|
+
yield fig
|
|
1407
|
+
finally:
|
|
1408
|
+
if filename:
|
|
1409
|
+
if tight_layout:
|
|
1410
|
+
fig.tight_layout()
|
|
1411
|
+
fig.savefig(filename, bbox_inches='tight')
|
|
1412
|
+
|
|
1413
|
+
if show:
|
|
1414
|
+
try:
|
|
1415
|
+
from IPython.core.display import display
|
|
1416
|
+
|
|
1417
|
+
# That should display the figure
|
|
1418
|
+
display(fig)
|
|
1419
|
+
except:
|
|
1420
|
+
pass
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def plot_outline(x, y, *args, ax=None, **kwargs):
|
|
1424
|
+
"""
|
|
1425
|
+
Plot the convex hull outline of (x, y) points.
|
|
1426
|
+
|
|
1427
|
+
Parameters
|
|
1428
|
+
----------
|
|
1429
|
+
x, y : array-like
|
|
1430
|
+
Coordinates of points to outline.
|
|
1431
|
+
*args, **kwargs :
|
|
1432
|
+
Passed through to `matplotlib.axes.Axes.plot` for line styling.
|
|
1433
|
+
ax : matplotlib.axes.Axes or None
|
|
1434
|
+
Target axes; defaults to current axes when None.
|
|
1435
|
+
|
|
1436
|
+
Notes
|
|
1437
|
+
-----
|
|
1438
|
+
- Uses `scipy.spatial.ConvexHull` to compute the outline.
|
|
1439
|
+
- Requires at least 3 non-collinear points; otherwise ConvexHull may fail.
|
|
1440
|
+
- If you pass masked arrays or NaNs, pre-filter to finite values to avoid
|
|
1441
|
+
distorting the hull.
|
|
1442
|
+
"""
|
|
1443
|
+
from scipy.spatial import ConvexHull
|
|
1444
|
+
|
|
1445
|
+
points = np.column_stack((np.array(x), np.array(y)))
|
|
1446
|
+
if points.shape[0] < 3:
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
hull = ConvexHull(points)
|
|
1450
|
+
|
|
1451
|
+
if ax is None:
|
|
1452
|
+
ax = plt.gca()
|
|
1453
|
+
|
|
1454
|
+
kw = kwargs.copy()
|
|
1455
|
+
|
|
1456
|
+
for simplex in hull.simplices:
|
|
1457
|
+
ax.plot(points[simplex, 0], points[simplex, 1], *args, **kw)
|
|
1458
|
+
if 'label' in kw:
|
|
1459
|
+
kw.pop('label')
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def cornerplot(
|
|
1463
|
+
features, scales=None, lines=None, subsets=None, show_all=True, extra=None, fig=None, **kwargs
|
|
1464
|
+
):
|
|
1465
|
+
"""
|
|
1466
|
+
Plot pairwise feature scatter panels in an upper-left triangular corner layout.
|
|
1467
|
+
|
|
1468
|
+
Parameters
|
|
1469
|
+
----------
|
|
1470
|
+
features : list[tuple[array-like, str]]
|
|
1471
|
+
List of (values, label) pairs. Each values array is 1D and aligned.
|
|
1472
|
+
scales : list[str] or None
|
|
1473
|
+
Per-feature axis scale (e.g., 'linear', 'log'); length must match features.
|
|
1474
|
+
lines : list[float] or None
|
|
1475
|
+
Per-feature reference line positions; length must match features.
|
|
1476
|
+
subsets : list[dict] or None
|
|
1477
|
+
Optional subsets to overlay. Each dict must include an 'idx' boolean mask,
|
|
1478
|
+
plus any matplotlib style kwargs (e.g., label, color, marker).
|
|
1479
|
+
extra : callable or None
|
|
1480
|
+
If set, this callable will be called for every panel, with axes instance
|
|
1481
|
+
and two features (for x and y axes) as arguments
|
|
1482
|
+
show_all : bool
|
|
1483
|
+
If True, plot the full dataset in each panel excluding subset masks.
|
|
1484
|
+
fig : matplotlib.figure.Figure or None
|
|
1485
|
+
Figure to plot into; a new figure is created when None.
|
|
1486
|
+
**kwargs :
|
|
1487
|
+
Additional matplotlib plot kwargs for the main dataset and subsets.
|
|
1488
|
+
|
|
1489
|
+
Notes
|
|
1490
|
+
-----
|
|
1491
|
+
- Only off-diagonal panels are drawn, resulting in a (N-1)x(N-1) grid.
|
|
1492
|
+
- Subsets are excluded from the "all data" layer when `show_all=True`.
|
|
1493
|
+
"""
|
|
1494
|
+
|
|
1495
|
+
if fig is None:
|
|
1496
|
+
fig = plt.figure()
|
|
1497
|
+
|
|
1498
|
+
N = len(features)
|
|
1499
|
+
|
|
1500
|
+
assert scales is None or len(scales) == len(features)
|
|
1501
|
+
assert lines is None or len(lines) == len(features)
|
|
1502
|
+
|
|
1503
|
+
if subsets is None:
|
|
1504
|
+
subsets = []
|
|
1505
|
+
|
|
1506
|
+
# Reasonable defaults
|
|
1507
|
+
kwargs = kwargs.copy()
|
|
1508
|
+
# Plot with markers without lines
|
|
1509
|
+
kwargs.setdefault('ls', 'none')
|
|
1510
|
+
kwargs.setdefault('marker', '.')
|
|
1511
|
+
|
|
1512
|
+
i = 0
|
|
1513
|
+
for iy in range(1, N):
|
|
1514
|
+
j = 0
|
|
1515
|
+
for ix in range(0, N):
|
|
1516
|
+
if ix == iy:
|
|
1517
|
+
continue
|
|
1518
|
+
|
|
1519
|
+
i += 1
|
|
1520
|
+
j += 1
|
|
1521
|
+
|
|
1522
|
+
if j > (N - iy):
|
|
1523
|
+
continue
|
|
1524
|
+
|
|
1525
|
+
col_x, col_y = features[ix], features[iy]
|
|
1526
|
+
|
|
1527
|
+
ax = fig.add_subplot(N - 1, N - 1, i)
|
|
1528
|
+
ax.grid(alpha=0.2)
|
|
1529
|
+
|
|
1530
|
+
if ix == 0:
|
|
1531
|
+
ax.set_ylabel(col_y[1]) # , rotation=105, labelpad=20)
|
|
1532
|
+
if iy == N - 1 or ix > iy:
|
|
1533
|
+
ax.set_xlabel(col_x[1]) # , rotation=15)
|
|
1534
|
+
|
|
1535
|
+
# Main plotting
|
|
1536
|
+
|
|
1537
|
+
if show_all:
|
|
1538
|
+
idx0 = np.isfinite(col_x[0]) & np.isfinite(col_y[0])
|
|
1539
|
+
# Exclude user defined subsets
|
|
1540
|
+
for sub in subsets:
|
|
1541
|
+
idx0 &= ~sub['idx']
|
|
1542
|
+
|
|
1543
|
+
ax.plot(col_x[0][idx0], col_y[0][idx0], **kwargs)
|
|
1544
|
+
|
|
1545
|
+
for sub in subsets:
|
|
1546
|
+
sub = sub.copy()
|
|
1547
|
+
idx = sub.pop('idx')
|
|
1548
|
+
|
|
1549
|
+
kw = kwargs.copy()
|
|
1550
|
+
kw.update(sub)
|
|
1551
|
+
|
|
1552
|
+
ax.plot(
|
|
1553
|
+
col_x[0][idx],
|
|
1554
|
+
col_y[0][idx],
|
|
1555
|
+
**kw,
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
if callable(extra):
|
|
1559
|
+
extra(ax, col_x, col_y)
|
|
1560
|
+
|
|
1561
|
+
if scales is not None:
|
|
1562
|
+
ax.set_xscale(scales[ix])
|
|
1563
|
+
ax.set_yscale(scales[iy])
|
|
1564
|
+
|
|
1565
|
+
if lines is not None:
|
|
1566
|
+
if lines[ix] is not None:
|
|
1567
|
+
ax.axvline(lines[ix], ls='--', color='gray')
|
|
1568
|
+
if lines[iy] is not None:
|
|
1569
|
+
ax.axhline(lines[iy], ls='--', color='gray')
|
|
1570
|
+
|
|
1571
|
+
if ix > 0:
|
|
1572
|
+
ax.set_yticklabels([])
|
|
1573
|
+
if iy < N - 1 and ix <= iy:
|
|
1574
|
+
ax.set_xticklabels([])
|
|
1575
|
+
|
|
1576
|
+
if len(subsets):
|
|
1577
|
+
legend = ax.legend()
|
|
1578
|
+
|
|
1579
|
+
for _ in legend.legend_handles:
|
|
1580
|
+
_.set_alpha(1)
|
|
1581
|
+
|
|
1582
|
+
fig.subplots_adjust(wspace=0.05, hspace=0.05)
|