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/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)